first commit
This commit is contained in:
commit
7e9406a8ae
|
|
@ -0,0 +1,6 @@
|
||||||
|
# Copy to .env and adjust. The admin account is created on first run if the DB is empty.
|
||||||
|
PORT=3000
|
||||||
|
SESSION_SECRET=change-me-to-a-long-random-string
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=change-me
|
||||||
|
ADMIN_EMAIL=admin@example.com
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
db/*.db
|
||||||
|
db/*.db-shm
|
||||||
|
db/*.db-wal
|
||||||
|
public/vendor/
|
||||||
|
*.log
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
# Bowler Chart
|
||||||
|
|
||||||
|
A small, self-hosted web app for a team to collaboratively build and report on a
|
||||||
|
**bowler chart** (a.k.a. bowling chart) — tracking project and KPI status month by month.
|
||||||
|
|
||||||
|
- **Everyone** can view the whole chart.
|
||||||
|
- Each **user** can create/edit/delete only **their own** projects and KPIs.
|
||||||
|
- A single **administrator** manages users and color schemes.
|
||||||
|
- **Material 3** UI (Beer CSS) with admin-defined, per-user-switchable color schemes.
|
||||||
|
|
||||||
|
## Tech stack
|
||||||
|
|
||||||
|
| Layer | Choice |
|
||||||
|
|---|---|
|
||||||
|
| Server | Node.js + Express |
|
||||||
|
| Database | SQLite (`better-sqlite3`) |
|
||||||
|
| Sessions | `express-session` + SQLite store |
|
||||||
|
| Passwords | `bcryptjs` |
|
||||||
|
| Hardening | `helmet` + double-submit CSRF tokens |
|
||||||
|
| UI | Beer CSS (Material 3) + Alpine.js, **no build step** |
|
||||||
|
| Theming | `material-dynamic-colors` (seed color → full palette) |
|
||||||
|
|
||||||
|
Front-end libraries are **vendored** into `public/vendor/` (no remote CDN calls).
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install # installs deps (needs Node 18+; tested on Node 24)
|
||||||
|
npm run vendor # copies UI libs from node_modules into public/vendor
|
||||||
|
cp .env.example .env # then edit .env (set ADMIN_PASSWORD + SESSION_SECRET)
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:3000 and sign in with the admin credentials from `.env`.
|
||||||
|
The admin account and two starter color schemes are created automatically on first run
|
||||||
|
(only when the database is empty).
|
||||||
|
|
||||||
|
> Native module note: `better-sqlite3` must be **v12+** on Node 24 — earlier versions
|
||||||
|
> have no prebuilt binary for Node 24 and would require a C/Python build toolchain.
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
server.js Express bootstrap, middleware, route mounting
|
||||||
|
db/ schema.sql + connection/seed (db/index.js)
|
||||||
|
src/
|
||||||
|
auth.js credential check, hashing, CSRF token
|
||||||
|
middleware/ requireAuth, requireAdmin, csrf, requireOwnerOrAdmin
|
||||||
|
routes/ auth, users, schemes, chart, projects, kpis, entries
|
||||||
|
public/ login.html, index.html, admin.html, css, js, vendor
|
||||||
|
scripts/vendor.js copies UI libs into public/vendor
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
`users` → owns → `projects` → has → `kpis` → has 12 monthly → `entries`
|
||||||
|
(`value` + manual `status`: green / amber / red / none). `color_schemes` define a
|
||||||
|
seed color + light/dark mode; users pick a preferred one (falls back to the default).
|
||||||
|
|
||||||
|
## Authorization
|
||||||
|
|
||||||
|
- View endpoints (`GET /api/chart`, `GET /api/schemes`) require only login.
|
||||||
|
- Mutations re-check ownership server-side: a user may modify a project (and its
|
||||||
|
KPIs/entries) only if they own it; the admin may modify anything.
|
||||||
|
- All state-changing requests must include the `X-CSRF-Token` header (issued via
|
||||||
|
`GET /api/me` and login).
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
|
||||||
|
const DB_PATH = process.env.DB_PATH || path.join(__dirname, 'bowler.db');
|
||||||
|
const SCHEMA_PATH = path.join(__dirname, 'schema.sql');
|
||||||
|
|
||||||
|
const db = new Database(DB_PATH);
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
db.pragma('foreign_keys = ON');
|
||||||
|
|
||||||
|
// Apply schema (idempotent — all statements use IF NOT EXISTS).
|
||||||
|
db.exec(fs.readFileSync(SCHEMA_PATH, 'utf8'));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First-run seed: if there are no users yet, create the admin from env vars
|
||||||
|
* and a sensible default color scheme.
|
||||||
|
*/
|
||||||
|
function seed() {
|
||||||
|
const userCount = db.prepare('SELECT COUNT(*) AS n FROM users').get().n;
|
||||||
|
if (userCount > 0) return;
|
||||||
|
|
||||||
|
const schemeCount = db.prepare('SELECT COUNT(*) AS n FROM color_schemes').get().n;
|
||||||
|
let defaultSchemeId;
|
||||||
|
if (schemeCount === 0) {
|
||||||
|
const info = db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO color_schemes (name, seed_color, mode, is_default)
|
||||||
|
VALUES (@name, @seed_color, @mode, 1)`
|
||||||
|
)
|
||||||
|
.run({ name: 'Default', seed_color: '#1565c0', mode: 'light' });
|
||||||
|
defaultSchemeId = info.lastInsertRowid;
|
||||||
|
// A second scheme so the picker has something to toggle out of the box.
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO color_schemes (name, seed_color, mode, is_default)
|
||||||
|
VALUES (@name, @seed_color, @mode, 0)`
|
||||||
|
).run({ name: 'Midnight', seed_color: '#7e57c2', mode: 'dark' });
|
||||||
|
} else {
|
||||||
|
defaultSchemeId = db
|
||||||
|
.prepare('SELECT id FROM color_schemes ORDER BY is_default DESC, id ASC LIMIT 1')
|
||||||
|
.get().id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = process.env.ADMIN_USERNAME || 'admin';
|
||||||
|
const password = process.env.ADMIN_PASSWORD || 'change-me';
|
||||||
|
const email = process.env.ADMIN_EMAIL || null;
|
||||||
|
const hash = bcrypt.hashSync(password, 10);
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO users (username, email, password_hash, role, preferred_scheme_id, active)
|
||||||
|
VALUES (@username, @email, @hash, 'admin', @scheme, 1)`
|
||||||
|
).run({ username, email, hash, scheme: defaultSchemeId });
|
||||||
|
|
||||||
|
console.log(`[seed] Created admin user "${username}" and default color schemes.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
seed();
|
||||||
|
|
||||||
|
module.exports = db;
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
-- Bowler Chart schema. Applied idempotently on startup.
|
||||||
|
PRAGMA journal_mode = WAL;
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS color_schemes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
seed_color TEXT NOT NULL, -- #rrggbb
|
||||||
|
mode TEXT NOT NULL DEFAULT 'light', -- 'light' | 'dark'
|
||||||
|
is_default INTEGER NOT NULL DEFAULT 0, -- 0/1; exactly one row should be 1
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
email TEXT,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user', -- 'admin' | 'user'
|
||||||
|
preferred_scheme_id INTEGER REFERENCES color_schemes(id) ON DELETE SET NULL,
|
||||||
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS kpis (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
unit TEXT,
|
||||||
|
goal_text TEXT,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS entries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
kpi_id INTEGER NOT NULL REFERENCES kpis(id) ON DELETE CASCADE,
|
||||||
|
year INTEGER NOT NULL,
|
||||||
|
month INTEGER NOT NULL CHECK (month BETWEEN 1 AND 12),
|
||||||
|
value TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'none', -- 'green' | 'amber' | 'red' | 'none'
|
||||||
|
note TEXT,
|
||||||
|
updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
UNIQUE (kpi_id, year, month)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_projects_owner ON projects(owner_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_kpis_project ON kpis(project_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_entries_kpi ON entries(kpi_id, year);
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"name": "bowler-chart",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Collaborative bowler chart (bowling chart) for tracking project and KPI status",
|
||||||
|
"type": "commonjs",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "node --watch server.js",
|
||||||
|
"vendor": "node scripts/vendor.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"better-sqlite3": "^12.2.0",
|
||||||
|
"better-sqlite3-session-store": "^0.1.0",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"express": "^4.21.0",
|
||||||
|
"express-session": "^1.18.0",
|
||||||
|
"helmet": "^8.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"alpinejs": "^3.14.1",
|
||||||
|
"beercss": "^3.7.0",
|
||||||
|
"material-dynamic-colors": "^1.1.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,144 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Admin · Bowler Chart</title>
|
||||||
|
<link rel="stylesheet" href="/vendor/beer.min.css" />
|
||||||
|
<link rel="stylesheet" href="/css/app.css" />
|
||||||
|
<script src="/vendor/material-dynamic-colors.min.js"></script>
|
||||||
|
<script src="/vendor/beer.min.js"></script>
|
||||||
|
<script src="/js/api.js"></script>
|
||||||
|
<script src="/js/theme.js"></script>
|
||||||
|
<script src="/js/admin.js" defer></script>
|
||||||
|
<script src="/vendor/alpine.min.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body class="light" x-data="adminApp()" x-init="init()" x-cloak>
|
||||||
|
|
||||||
|
<nav class="top fill">
|
||||||
|
<button class="circle transparent" @click="location.href='/'" title="Back to chart"><i>arrow_back</i></button>
|
||||||
|
<h6 class="app-bar-title max">Administration</h6>
|
||||||
|
<span class="chip"><i>shield_person</i><span x-text="me?.username"></span></span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="responsive">
|
||||||
|
<div class="tabs">
|
||||||
|
<a :class="tab === 'users' ? 'active' : ''" @click="tab='users'"><i>group</i><span>Users</span></a>
|
||||||
|
<a :class="tab === 'schemes' ? 'active' : ''" @click="tab='schemes'"><i>palette</i><span>Color schemes</span></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- USERS -->
|
||||||
|
<div x-show="tab === 'users'" class="page active">
|
||||||
|
<div class="row middle-align" style="margin: 1rem 0;">
|
||||||
|
<h6 class="max">Users</h6>
|
||||||
|
<button @click="openNewUser()"><i>person_add</i><span>New user</span></button>
|
||||||
|
</div>
|
||||||
|
<table class="border stripes">
|
||||||
|
<thead><tr><th>Username</th><th>Email</th><th>Role</th><th>Status</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="u in users" :key="u.id">
|
||||||
|
<tr>
|
||||||
|
<td x-text="u.username"></td>
|
||||||
|
<td class="muted" x-text="u.email || '—'"></td>
|
||||||
|
<td><span class="chip small" x-text="u.role"></span></td>
|
||||||
|
<td><span :class="u.active ? 'green-text' : 'red-text'" x-text="u.active ? 'Active' : 'Disabled'"></span></td>
|
||||||
|
<td class="right-align nowrap">
|
||||||
|
<button class="circle small transparent" @click="openEditUser(u)"><i>edit</i></button>
|
||||||
|
<button class="circle small transparent" @click="deleteUser(u)"><i>delete</i></button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SCHEMES -->
|
||||||
|
<div x-show="tab === 'schemes'" class="page active">
|
||||||
|
<div class="row middle-align" style="margin: 1rem 0;">
|
||||||
|
<h6 class="max">Color schemes</h6>
|
||||||
|
<button @click="openNewScheme()"><i>add</i><span>New scheme</span></button>
|
||||||
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
<template x-for="s in schemes" :key="s.id">
|
||||||
|
<article class="s12 m6 l4 border round">
|
||||||
|
<div class="row middle-align">
|
||||||
|
<div class="square round" :style="`background:${s.seed_color}`"></div>
|
||||||
|
<div class="max">
|
||||||
|
<h6 class="small" x-text="s.name"></h6>
|
||||||
|
<span class="muted small-text" x-text="`${s.seed_color} · ${s.mode}`"></span>
|
||||||
|
</div>
|
||||||
|
<template x-if="s.is_default"><span class="chip small primary">Default</span></template>
|
||||||
|
</div>
|
||||||
|
<nav class="right-align">
|
||||||
|
<button class="circle small transparent" @click="openEditScheme(s)"><i>edit</i></button>
|
||||||
|
<button class="circle small transparent" @click="deleteScheme(s)" :disabled="s.is_default"><i>delete</i></button>
|
||||||
|
</nav>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- User dialog -->
|
||||||
|
<dialog class="modal" x-ref="userDialog">
|
||||||
|
<h6 x-text="user.id ? 'Edit user' : 'New user'"></h6>
|
||||||
|
<div class="field label border round" x-show="!user.id">
|
||||||
|
<input type="text" x-model="user.username" autocomplete="off" />
|
||||||
|
<label>Username</label>
|
||||||
|
</div>
|
||||||
|
<div class="field label border round">
|
||||||
|
<input type="email" x-model="user.email" autocomplete="off" />
|
||||||
|
<label>Email</label>
|
||||||
|
</div>
|
||||||
|
<div class="field label border round">
|
||||||
|
<input type="password" x-model="user.password" autocomplete="new-password" />
|
||||||
|
<label x-text="user.id ? 'New password (leave blank to keep)' : 'Password'"></label>
|
||||||
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="field label border round s6">
|
||||||
|
<select x-model="user.role"><option value="user">User</option><option value="admin">Admin</option></select>
|
||||||
|
<label>Role</label>
|
||||||
|
</div>
|
||||||
|
<label class="checkbox s6" style="align-self:center;">
|
||||||
|
<input type="checkbox" x-model="user.active" /><span>Active</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<nav class="right-align">
|
||||||
|
<button class="border" @click="$refs.userDialog.close()">Cancel</button>
|
||||||
|
<button @click="saveUser()"><i>save</i><span>Save</span></button>
|
||||||
|
</nav>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<!-- Scheme dialog -->
|
||||||
|
<dialog class="modal" x-ref="schemeDialog">
|
||||||
|
<h6 x-text="scheme.id ? 'Edit scheme' : 'New scheme'"></h6>
|
||||||
|
<div class="field label border round">
|
||||||
|
<input type="text" x-model="scheme.name" />
|
||||||
|
<label>Name</label>
|
||||||
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="field label border round s6">
|
||||||
|
<input type="color" x-model="scheme.seed_color" @input="previewScheme()" style="height:3rem;" />
|
||||||
|
<label>Seed color</label>
|
||||||
|
</div>
|
||||||
|
<div class="field label border round s6">
|
||||||
|
<select x-model="scheme.mode" @change="previewScheme()">
|
||||||
|
<option value="light">Light</option><option value="dark">Dark</option>
|
||||||
|
</select>
|
||||||
|
<label>Mode</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" x-model="scheme.is_default" /><span>Use as default for new users</span>
|
||||||
|
</label>
|
||||||
|
<p class="muted small-text">Tip: the live UI previews this color as you adjust it.</p>
|
||||||
|
<nav class="right-align">
|
||||||
|
<button class="border" @click="$refs.schemeDialog.close(); Theme.apply(Theme.resolve(schemes, me?.preferred_scheme_id))">Cancel</button>
|
||||||
|
<button @click="saveScheme()"><i>save</i><span>Save</span></button>
|
||||||
|
</nav>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<div class="snackbar error" id="snackbar"><span x-text="error"></span></div>
|
||||||
|
<style>[x-cloak]{display:none!important;}</style>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
/* App-specific styling layered on top of Beer CSS (Material 3). */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--status-green: #2e7d32;
|
||||||
|
--status-amber: #f9a825;
|
||||||
|
--status-red: #c62828;
|
||||||
|
--status-none: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
body { padding-bottom: 4rem; }
|
||||||
|
|
||||||
|
.app-bar-title { font-weight: 600; letter-spacing: 0.2px; }
|
||||||
|
|
||||||
|
/* ---- Bowler chart grid ---- */
|
||||||
|
.bowler-wrap { overflow-x: auto; }
|
||||||
|
|
||||||
|
table.bowler {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 64rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.bowler th,
|
||||||
|
table.bowler td {
|
||||||
|
border: 1px solid var(--outline-variant, #cac4d0);
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.bowler thead th {
|
||||||
|
background: var(--surface-container-high, #ece6f0);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Frozen first column (KPI name) */
|
||||||
|
table.bowler .kpi-col {
|
||||||
|
text-align: left;
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
background: var(--surface, #fef7ff);
|
||||||
|
z-index: 1;
|
||||||
|
min-width: 16rem;
|
||||||
|
max-width: 22rem;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.bowler tr.project-row td {
|
||||||
|
text-align: left;
|
||||||
|
background: var(--secondary-container, #e8def8);
|
||||||
|
color: var(--on-secondary-container, #1d192b);
|
||||||
|
font-weight: 600;
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status cells */
|
||||||
|
.cell { cursor: default; }
|
||||||
|
.cell.editable { cursor: pointer; }
|
||||||
|
.cell.editable:hover { outline: 2px solid var(--primary, #6750a4); outline-offset: -2px; }
|
||||||
|
|
||||||
|
.cell.green { background: var(--status-green); color: #fff; }
|
||||||
|
.cell.amber { background: var(--status-amber); color: #000; }
|
||||||
|
.cell.red { background: var(--status-red); color: #fff; }
|
||||||
|
.cell.none { background: var(--status-none); }
|
||||||
|
|
||||||
|
.cell .cell-value { font-weight: 600; }
|
||||||
|
.cell.has-note::after {
|
||||||
|
content: '•';
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 4px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.cell { position: relative; }
|
||||||
|
|
||||||
|
/* Sparkline trend column */
|
||||||
|
.trend-col svg { display: block; }
|
||||||
|
|
||||||
|
.muted { opacity: 0.7; }
|
||||||
|
.right { text-align: right; }
|
||||||
|
.nowrap { white-space: nowrap; }
|
||||||
|
|
||||||
|
/* Login screen */
|
||||||
|
.login-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
.login-card { width: min(92vw, 24rem); }
|
||||||
|
|
||||||
|
dialog.modal { width: min(94vw, 32rem); }
|
||||||
|
|
@ -0,0 +1,212 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Bowler Chart</title>
|
||||||
|
<link rel="stylesheet" href="/vendor/beer.min.css" />
|
||||||
|
<link rel="stylesheet" href="/css/app.css" />
|
||||||
|
<script src="/vendor/material-dynamic-colors.min.js"></script>
|
||||||
|
<script src="/vendor/beer.min.js"></script>
|
||||||
|
<script src="/js/api.js"></script>
|
||||||
|
<script src="/js/theme.js"></script>
|
||||||
|
<script src="/js/app.js" defer></script>
|
||||||
|
<script src="/vendor/alpine.min.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body class="light" x-data="bowlerApp()" x-init="init()" x-cloak>
|
||||||
|
|
||||||
|
<!-- Top app bar -->
|
||||||
|
<nav class="top fill">
|
||||||
|
<i>insights</i>
|
||||||
|
<h6 class="app-bar-title max">Bowler Chart</h6>
|
||||||
|
|
||||||
|
<div class="field label suffix small border" style="margin: 0 0.5rem; width: 7rem;">
|
||||||
|
<select x-model.number="year" @change="loadChart()">
|
||||||
|
<template x-for="y in years" :key="y"><option :value="y" x-text="y"></option></template>
|
||||||
|
</select>
|
||||||
|
<label>Year</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field label suffix small border" style="margin: 0 0.5rem; width: 11rem;"
|
||||||
|
x-show="schemes.length">
|
||||||
|
<select @change="changeScheme($event.target.value)">
|
||||||
|
<template x-for="s in schemes" :key="s.id">
|
||||||
|
<option :value="s.id" :selected="s.id === me?.preferred_scheme_id" x-text="s.name"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
<label>Theme</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="circle transparent" x-show="isAdmin" @click="location.href='/admin.html'" title="Admin">
|
||||||
|
<i>admin_panel_settings</i>
|
||||||
|
</button>
|
||||||
|
<button class="chip" @click="logout()">
|
||||||
|
<i>logout</i>
|
||||||
|
<span x-text="me?.username"></span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="responsive">
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="row middle-align" style="margin: 1rem 0;">
|
||||||
|
<h5 class="max">KPI Status — <span x-text="year"></span></h5>
|
||||||
|
<button @click="openNewProject()">
|
||||||
|
<i>add</i><span>New project</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<progress class="circle large" x-show="loading"></progress>
|
||||||
|
|
||||||
|
<div class="bowler-wrap" x-show="!loading">
|
||||||
|
<article class="border round" x-show="!projects.length">
|
||||||
|
<p class="center-align muted">No projects yet. Create one to start tracking KPIs.</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<table class="bowler" x-show="projects.length">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="kpi-col">KPI</th>
|
||||||
|
<th>Goal</th>
|
||||||
|
<template x-for="m in months" :key="m"><th x-text="m"></th></template>
|
||||||
|
<th>Trend</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="p in projects" :key="p.id">
|
||||||
|
<template>
|
||||||
|
<!-- Project header row -->
|
||||||
|
<tr class="project-row">
|
||||||
|
<td :colspan="months.length + 4">
|
||||||
|
<div class="row middle-align">
|
||||||
|
<span class="max">
|
||||||
|
<i class="small">folder</i>
|
||||||
|
<span x-text="p.name"></span>
|
||||||
|
<span class="muted small-text">· <span x-text="p.owner_name"></span></span>
|
||||||
|
</span>
|
||||||
|
<template x-if="p.can_edit">
|
||||||
|
<span class="nowrap">
|
||||||
|
<button class="circle small transparent" @click="openNewKpi(p)" title="Add KPI"><i>add</i></button>
|
||||||
|
<button class="circle small transparent" @click="openEditProject(p)" title="Edit project"><i>edit</i></button>
|
||||||
|
<button class="circle small transparent" @click="deleteProject(p)" title="Delete project"><i>delete</i></button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- KPI rows -->
|
||||||
|
<template x-for="k in p.kpis" :key="k.id">
|
||||||
|
<tr>
|
||||||
|
<td class="kpi-col">
|
||||||
|
<strong x-text="k.name"></strong>
|
||||||
|
<template x-if="k.unit"><span class="muted small-text"> (<span x-text="k.unit"></span>)</span></template>
|
||||||
|
</td>
|
||||||
|
<td class="muted small-text" x-text="k.goal_text || '—'"></td>
|
||||||
|
<template x-for="m in k.months" :key="m.month">
|
||||||
|
<td class="cell" :class="[m.status, p.can_edit ? 'editable' : '', m.note ? 'has-note' : '']"
|
||||||
|
@click="openCell(p, k, m)" :title="m.note || ''">
|
||||||
|
<span class="cell-value" x-text="m.value ?? ''"></span>
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
<td class="trend-col">
|
||||||
|
<template x-if="sparkline(k)">
|
||||||
|
<svg width="88" height="24" viewBox="0 0 88 24">
|
||||||
|
<polyline :points="sparkline(k)" fill="none"
|
||||||
|
stroke="var(--primary, #6750a4)" stroke-width="2"
|
||||||
|
stroke-linejoin="round" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<template x-if="p.can_edit">
|
||||||
|
<span class="nowrap">
|
||||||
|
<button class="circle small transparent" @click="openEditKpi(p, k)" title="Edit KPI"><i>edit</i></button>
|
||||||
|
<button class="circle small transparent" @click="deleteKpi(k)" title="Delete KPI"><i>delete</i></button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Cell edit dialog -->
|
||||||
|
<dialog class="modal" x-ref="cellDialog">
|
||||||
|
<h6><span x-text="cell.kpiName"></span> — <span x-text="months[cell.month - 1]"></span> <span x-text="year"></span></h6>
|
||||||
|
<div class="field label border round">
|
||||||
|
<input type="text" x-model="cell.value" />
|
||||||
|
<label>Value</label>
|
||||||
|
</div>
|
||||||
|
<div class="field label border round">
|
||||||
|
<select x-model="cell.status">
|
||||||
|
<template x-for="o in statusOptions" :key="o.value">
|
||||||
|
<option :value="o.value" x-text="o.label"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
<label>Status</label>
|
||||||
|
</div>
|
||||||
|
<div class="field label border round textarea">
|
||||||
|
<textarea x-model="cell.note"></textarea>
|
||||||
|
<label>Note</label>
|
||||||
|
</div>
|
||||||
|
<nav class="right-align">
|
||||||
|
<button class="border" @click="$refs.cellDialog.close()">Cancel</button>
|
||||||
|
<button @click="saveCell()"><i>save</i><span>Save</span></button>
|
||||||
|
</nav>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<!-- Project dialog -->
|
||||||
|
<dialog class="modal" x-ref="projectDialog">
|
||||||
|
<h6 x-text="proj.id ? 'Edit project' : 'New project'"></h6>
|
||||||
|
<div class="field label border round">
|
||||||
|
<input type="text" x-model="proj.name" />
|
||||||
|
<label>Name</label>
|
||||||
|
</div>
|
||||||
|
<div class="field label border round textarea">
|
||||||
|
<textarea x-model="proj.description"></textarea>
|
||||||
|
<label>Description</label>
|
||||||
|
</div>
|
||||||
|
<nav class="right-align">
|
||||||
|
<button class="border" @click="$refs.projectDialog.close()">Cancel</button>
|
||||||
|
<button @click="saveProject()"><i>save</i><span>Save</span></button>
|
||||||
|
</nav>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<!-- KPI dialog -->
|
||||||
|
<dialog class="modal" x-ref="kpiDialog">
|
||||||
|
<h6 x-text="kpi.id ? 'Edit KPI' : 'New KPI'"></h6>
|
||||||
|
<div class="field label border round">
|
||||||
|
<input type="text" x-model="kpi.name" />
|
||||||
|
<label>Name</label>
|
||||||
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="field label border round s6">
|
||||||
|
<input type="text" x-model="kpi.unit" />
|
||||||
|
<label>Unit</label>
|
||||||
|
</div>
|
||||||
|
<div class="field label border round s6">
|
||||||
|
<input type="text" x-model="kpi.goal_text" />
|
||||||
|
<label>Goal</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field label border round textarea">
|
||||||
|
<textarea x-model="kpi.description"></textarea>
|
||||||
|
<label>Description</label>
|
||||||
|
</div>
|
||||||
|
<nav class="right-align">
|
||||||
|
<button class="border" @click="$refs.kpiDialog.close()">Cancel</button>
|
||||||
|
<button @click="saveKpi()"><i>save</i><span>Save</span></button>
|
||||||
|
</nav>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<!-- Snackbar for errors -->
|
||||||
|
<div class="snackbar error" id="snackbar"><span x-text="error"></span></div>
|
||||||
|
|
||||||
|
<style>[x-cloak]{display:none!important;}</style>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function adminApp() {
|
||||||
|
return {
|
||||||
|
me: null,
|
||||||
|
users: [],
|
||||||
|
schemes: [],
|
||||||
|
error: '',
|
||||||
|
tab: 'users',
|
||||||
|
|
||||||
|
user: { id: null, username: '', email: '', password: '', role: 'user', active: true },
|
||||||
|
scheme: { id: null, name: '', seed_color: '#1565c0', mode: 'light', is_default: false },
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
try {
|
||||||
|
const me = await API.get('/api/me');
|
||||||
|
this.me = me.user;
|
||||||
|
if (me.user.role !== 'admin') { window.location.href = '/'; return; }
|
||||||
|
} catch (_) {
|
||||||
|
window.location.href = '/login.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.loadSchemes();
|
||||||
|
await this.loadUsers();
|
||||||
|
const scheme = Theme.resolve(this.schemes, this.me.preferred_scheme_id);
|
||||||
|
await Theme.apply(scheme);
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadUsers() {
|
||||||
|
try { this.users = (await API.get('/api/users')).users; }
|
||||||
|
catch (e) { this.toast(e.message); }
|
||||||
|
},
|
||||||
|
async loadSchemes() {
|
||||||
|
try { this.schemes = (await API.get('/api/schemes')).schemes; }
|
||||||
|
catch (e) { this.toast(e.message); }
|
||||||
|
},
|
||||||
|
schemeName(id) {
|
||||||
|
const s = this.schemes.find((x) => x.id === id);
|
||||||
|
return s ? s.name : '—';
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- Users ----
|
||||||
|
openNewUser() {
|
||||||
|
this.user = { id: null, username: '', email: '', password: '', role: 'user', active: true };
|
||||||
|
this.$refs.userDialog.showModal();
|
||||||
|
},
|
||||||
|
openEditUser(u) {
|
||||||
|
this.user = { id: u.id, username: u.username, email: u.email || '', password: '', role: u.role, active: !!u.active };
|
||||||
|
this.$refs.userDialog.showModal();
|
||||||
|
},
|
||||||
|
async saveUser() {
|
||||||
|
try {
|
||||||
|
if (this.user.id) {
|
||||||
|
const body = { email: this.user.email, role: this.user.role, active: this.user.active };
|
||||||
|
if (this.user.password) body.password = this.user.password;
|
||||||
|
await API.patch(`/api/users/${this.user.id}`, body);
|
||||||
|
} else {
|
||||||
|
await API.post('/api/users', {
|
||||||
|
username: this.user.username,
|
||||||
|
email: this.user.email,
|
||||||
|
password: this.user.password,
|
||||||
|
role: this.user.role,
|
||||||
|
active: this.user.active,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.$refs.userDialog.close();
|
||||||
|
await this.loadUsers();
|
||||||
|
} catch (e) { this.toast(e.message); }
|
||||||
|
},
|
||||||
|
async deleteUser(u) {
|
||||||
|
if (!confirm(`Delete user "${u.username}"? Their projects and KPIs will also be removed.`)) return;
|
||||||
|
try { await API.del(`/api/users/${u.id}`); await this.loadUsers(); }
|
||||||
|
catch (e) { this.toast(e.message); }
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- Schemes ----
|
||||||
|
openNewScheme() {
|
||||||
|
this.scheme = { id: null, name: '', seed_color: '#1565c0', mode: 'light', is_default: false };
|
||||||
|
this.$refs.schemeDialog.showModal();
|
||||||
|
},
|
||||||
|
openEditScheme(s) {
|
||||||
|
this.scheme = { id: s.id, name: s.name, seed_color: s.seed_color, mode: s.mode, is_default: !!s.is_default };
|
||||||
|
this.$refs.schemeDialog.showModal();
|
||||||
|
},
|
||||||
|
previewScheme() {
|
||||||
|
Theme.apply({ seed_color: this.scheme.seed_color, mode: this.scheme.mode });
|
||||||
|
},
|
||||||
|
async saveScheme() {
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
name: this.scheme.name,
|
||||||
|
seed_color: this.scheme.seed_color,
|
||||||
|
mode: this.scheme.mode,
|
||||||
|
is_default: this.scheme.is_default,
|
||||||
|
};
|
||||||
|
if (this.scheme.id) await API.patch(`/api/schemes/${this.scheme.id}`, body);
|
||||||
|
else await API.post('/api/schemes', body);
|
||||||
|
this.$refs.schemeDialog.close();
|
||||||
|
await this.loadSchemes();
|
||||||
|
// Restore the admin's own theme after previewing.
|
||||||
|
await Theme.apply(Theme.resolve(this.schemes, this.me.preferred_scheme_id));
|
||||||
|
} catch (e) { this.toast(e.message); }
|
||||||
|
},
|
||||||
|
async deleteScheme(s) {
|
||||||
|
if (!confirm(`Delete scheme "${s.name}"?`)) return;
|
||||||
|
try { await API.del(`/api/schemes/${s.id}`); await this.loadSchemes(); }
|
||||||
|
catch (e) { this.toast(e.message); }
|
||||||
|
},
|
||||||
|
|
||||||
|
toast(msg) {
|
||||||
|
this.error = msg;
|
||||||
|
if (typeof window.ui === 'function') window.ui('#snackbar');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
window.adminApp = adminApp;
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Tiny fetch wrapper. Tracks the CSRF token (from GET /api/me or login) and
|
||||||
|
// attaches it to every state-changing request as X-CSRF-Token.
|
||||||
|
window.API = (function () {
|
||||||
|
let csrfToken = null;
|
||||||
|
|
||||||
|
async function request(method, url, body) {
|
||||||
|
const headers = {};
|
||||||
|
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||||
|
if (method !== 'GET' && csrfToken) headers['X-CSRF-Token'] = csrfToken;
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
let data = null;
|
||||||
|
const text = await res.text();
|
||||||
|
if (text) {
|
||||||
|
try { data = JSON.parse(text); } catch (_) { data = { raw: text }; }
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = new Error((data && data.error) || `Request failed (${res.status})`);
|
||||||
|
err.status = res.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (data && data.csrfToken) csrfToken = data.csrfToken;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
setCsrf: (t) => { csrfToken = t; },
|
||||||
|
get: (url) => request('GET', url),
|
||||||
|
post: (url, body) => request('POST', url, body || {}),
|
||||||
|
patch: (url, body) => request('PATCH', url, body || {}),
|
||||||
|
put: (url, body) => request('PUT', url, body || {}),
|
||||||
|
del: (url) => request('DELETE', url),
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
@ -0,0 +1,242 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ value: 'green', label: 'On track' },
|
||||||
|
{ value: 'amber', label: 'At risk' },
|
||||||
|
{ value: 'red', label: 'Off track' },
|
||||||
|
{ value: 'none', label: 'No data' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function bowlerApp() {
|
||||||
|
return {
|
||||||
|
me: null,
|
||||||
|
isAdmin: false,
|
||||||
|
schemes: [],
|
||||||
|
year: new Date().getFullYear(),
|
||||||
|
years: [],
|
||||||
|
projects: [],
|
||||||
|
loading: true,
|
||||||
|
error: '',
|
||||||
|
months: MONTHS,
|
||||||
|
statusOptions: STATUS_OPTIONS,
|
||||||
|
|
||||||
|
// Dialog state
|
||||||
|
cell: { kpiId: null, kpiName: '', month: 1, value: '', status: 'none', note: '' },
|
||||||
|
proj: { id: null, name: '', description: '' },
|
||||||
|
kpi: { id: null, projectId: null, name: '', unit: '', goal_text: '', description: '' },
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
const y = new Date().getFullYear();
|
||||||
|
this.years = [y - 2, y - 1, y, y + 1];
|
||||||
|
try {
|
||||||
|
const me = await API.get('/api/me');
|
||||||
|
this.me = me.user;
|
||||||
|
this.isAdmin = me.user.role === 'admin';
|
||||||
|
} catch (_) {
|
||||||
|
window.location.href = '/login.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.loadSchemes();
|
||||||
|
await this.loadChart();
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadSchemes() {
|
||||||
|
try {
|
||||||
|
const { schemes } = await API.get('/api/schemes');
|
||||||
|
this.schemes = schemes;
|
||||||
|
const scheme = Theme.resolve(schemes, this.me.preferred_scheme_id);
|
||||||
|
await Theme.apply(scheme);
|
||||||
|
} catch (_) {}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadChart() {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
const data = await API.get(`/api/chart?year=${this.year}`);
|
||||||
|
this.projects = data.projects;
|
||||||
|
} catch (e) {
|
||||||
|
this.error = e.message;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
canEditAny() {
|
||||||
|
return this.projects.some((p) => p.can_edit);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- Theme ----
|
||||||
|
async changeScheme(id) {
|
||||||
|
const scheme = this.schemes.find((s) => s.id === Number(id));
|
||||||
|
await Theme.apply(scheme);
|
||||||
|
try {
|
||||||
|
await API.patch('/api/me/scheme', { scheme_id: scheme ? scheme.id : null });
|
||||||
|
this.me.preferred_scheme_id = scheme ? scheme.id : null;
|
||||||
|
} catch (e) {
|
||||||
|
this.toast(e.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- Cell editing ----
|
||||||
|
openCell(project, kpi, m) {
|
||||||
|
if (!project.can_edit) return;
|
||||||
|
this.cell = {
|
||||||
|
kpiId: kpi.id,
|
||||||
|
kpiName: kpi.name,
|
||||||
|
month: m.month,
|
||||||
|
value: m.value ?? '',
|
||||||
|
status: m.status || 'none',
|
||||||
|
note: m.note ?? '',
|
||||||
|
};
|
||||||
|
this.$refs.cellDialog.showModal();
|
||||||
|
},
|
||||||
|
async saveCell() {
|
||||||
|
try {
|
||||||
|
const { entry } = await API.put('/api/entries', {
|
||||||
|
kpi_id: this.cell.kpiId,
|
||||||
|
year: this.year,
|
||||||
|
month: this.cell.month,
|
||||||
|
value: this.cell.value,
|
||||||
|
status: this.cell.status,
|
||||||
|
note: this.cell.note,
|
||||||
|
});
|
||||||
|
// Patch local state in place.
|
||||||
|
for (const p of this.projects) {
|
||||||
|
const k = p.kpis.find((x) => x.id === this.cell.kpiId);
|
||||||
|
if (k) {
|
||||||
|
const slot = k.months[this.cell.month - 1];
|
||||||
|
slot.value = entry.value;
|
||||||
|
slot.status = entry.status;
|
||||||
|
slot.note = entry.note;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.$refs.cellDialog.close();
|
||||||
|
} catch (e) {
|
||||||
|
this.toast(e.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- Project CRUD ----
|
||||||
|
openNewProject() {
|
||||||
|
this.proj = { id: null, name: '', description: '' };
|
||||||
|
this.$refs.projectDialog.showModal();
|
||||||
|
},
|
||||||
|
openEditProject(p) {
|
||||||
|
this.proj = { id: p.id, name: p.name, description: p.description || '' };
|
||||||
|
this.$refs.projectDialog.showModal();
|
||||||
|
},
|
||||||
|
async saveProject() {
|
||||||
|
try {
|
||||||
|
if (this.proj.id) {
|
||||||
|
await API.patch(`/api/projects/${this.proj.id}`, {
|
||||||
|
name: this.proj.name,
|
||||||
|
description: this.proj.description,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await API.post('/api/projects', {
|
||||||
|
name: this.proj.name,
|
||||||
|
description: this.proj.description,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.$refs.projectDialog.close();
|
||||||
|
await this.loadChart();
|
||||||
|
} catch (e) {
|
||||||
|
this.toast(e.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async deleteProject(p) {
|
||||||
|
if (!confirm(`Delete project "${p.name}" and all its KPIs?`)) return;
|
||||||
|
try {
|
||||||
|
await API.del(`/api/projects/${p.id}`);
|
||||||
|
await this.loadChart();
|
||||||
|
} catch (e) {
|
||||||
|
this.toast(e.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- KPI CRUD ----
|
||||||
|
openNewKpi(project) {
|
||||||
|
this.kpi = { id: null, projectId: project.id, name: '', unit: '', goal_text: '', description: '' };
|
||||||
|
this.$refs.kpiDialog.showModal();
|
||||||
|
},
|
||||||
|
openEditKpi(project, k) {
|
||||||
|
this.kpi = {
|
||||||
|
id: k.id,
|
||||||
|
projectId: project.id,
|
||||||
|
name: k.name,
|
||||||
|
unit: k.unit || '',
|
||||||
|
goal_text: k.goal_text || '',
|
||||||
|
description: k.description || '',
|
||||||
|
};
|
||||||
|
this.$refs.kpiDialog.showModal();
|
||||||
|
},
|
||||||
|
async saveKpi() {
|
||||||
|
try {
|
||||||
|
if (this.kpi.id) {
|
||||||
|
await API.patch(`/api/kpis/${this.kpi.id}`, {
|
||||||
|
name: this.kpi.name,
|
||||||
|
unit: this.kpi.unit,
|
||||||
|
goal_text: this.kpi.goal_text,
|
||||||
|
description: this.kpi.description,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await API.post('/api/kpis', {
|
||||||
|
project_id: this.kpi.projectId,
|
||||||
|
name: this.kpi.name,
|
||||||
|
unit: this.kpi.unit,
|
||||||
|
goal_text: this.kpi.goal_text,
|
||||||
|
description: this.kpi.description,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.$refs.kpiDialog.close();
|
||||||
|
await this.loadChart();
|
||||||
|
} catch (e) {
|
||||||
|
this.toast(e.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async deleteKpi(k) {
|
||||||
|
if (!confirm(`Delete KPI "${k.name}"?`)) return;
|
||||||
|
try {
|
||||||
|
await API.del(`/api/kpis/${k.id}`);
|
||||||
|
await this.loadChart();
|
||||||
|
} catch (e) {
|
||||||
|
this.toast(e.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- Sparkline (numeric months only) ----
|
||||||
|
sparkline(kpi) {
|
||||||
|
const pts = kpi.months
|
||||||
|
.map((m, i) => ({ i, v: parseFloat(m.value) }))
|
||||||
|
.filter((p) => !Number.isNaN(p.v));
|
||||||
|
if (pts.length < 2) return '';
|
||||||
|
const xs = pts.map((p) => p.i);
|
||||||
|
const vs = pts.map((p) => p.v);
|
||||||
|
const minV = Math.min(...vs);
|
||||||
|
const maxV = Math.max(...vs);
|
||||||
|
const range = maxV - minV || 1;
|
||||||
|
const W = 88;
|
||||||
|
const H = 24;
|
||||||
|
const coords = pts.map((p) => {
|
||||||
|
const x = (p.i / 11) * W;
|
||||||
|
const y = H - ((p.v - minV) / range) * (H - 4) - 2;
|
||||||
|
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||||
|
});
|
||||||
|
return coords.join(' ');
|
||||||
|
},
|
||||||
|
|
||||||
|
async logout() {
|
||||||
|
try { await API.post('/api/logout'); } catch (_) {}
|
||||||
|
window.location.href = '/login.html';
|
||||||
|
},
|
||||||
|
|
||||||
|
toast(msg) {
|
||||||
|
this.error = msg;
|
||||||
|
if (typeof window.ui === 'function') window.ui('#snackbar');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
window.bowlerApp = bowlerApp;
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Applies a color scheme using Beer CSS's dynamic theming, which derives a full
|
||||||
|
// Material 3 palette from a single seed color. `ui()` is provided by beer.min.js.
|
||||||
|
window.Theme = (function () {
|
||||||
|
async function apply(scheme) {
|
||||||
|
if (!scheme || typeof window.ui !== 'function') return;
|
||||||
|
try {
|
||||||
|
await window.ui('theme', scheme.seed_color);
|
||||||
|
window.ui('mode', scheme.mode === 'dark' ? 'dark' : 'light');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Theme apply failed', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the scheme to show: the user's preferred one, else the default.
|
||||||
|
function resolve(schemes, preferredId) {
|
||||||
|
if (!schemes || !schemes.length) return null;
|
||||||
|
return (
|
||||||
|
schemes.find((s) => s.id === preferredId) ||
|
||||||
|
schemes.find((s) => s.is_default) ||
|
||||||
|
schemes[0]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { apply, resolve };
|
||||||
|
})();
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Sign in · Bowler Chart</title>
|
||||||
|
<link rel="stylesheet" href="/vendor/beer.min.css" />
|
||||||
|
<link rel="stylesheet" href="/css/app.css" />
|
||||||
|
<script src="/vendor/material-dynamic-colors.min.js"></script>
|
||||||
|
<script src="/vendor/beer.min.js"></script>
|
||||||
|
<script src="/js/api.js"></script>
|
||||||
|
<script src="/js/theme.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="light">
|
||||||
|
<main class="login-shell">
|
||||||
|
<article class="login-card large-padding round elevate">
|
||||||
|
<h4 class="center-align">Bowler Chart</h4>
|
||||||
|
<p class="center-align muted">Sign in to view and update your KPIs</p>
|
||||||
|
|
||||||
|
<form id="login-form" class="vertical">
|
||||||
|
<div class="field label border round">
|
||||||
|
<input type="text" id="username" autocomplete="username" required />
|
||||||
|
<label>Username</label>
|
||||||
|
</div>
|
||||||
|
<div class="field label border round">
|
||||||
|
<input type="password" id="password" autocomplete="current-password" required />
|
||||||
|
<label>Password</label>
|
||||||
|
</div>
|
||||||
|
<p id="error" class="error-text" style="color: var(--error, #b3261e); display: none;"></p>
|
||||||
|
<button class="responsive" type="submit">
|
||||||
|
<i>login</i>
|
||||||
|
<span>Sign in</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Apply the default scheme on the login screen for a themed first impression.
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const { schemes } = await API.get('/api/schemes').catch(() => ({ schemes: [] }));
|
||||||
|
const def = (schemes || []).find((s) => s.is_default) || (schemes || [])[0];
|
||||||
|
if (def) await Theme.apply(def);
|
||||||
|
} catch (_) {}
|
||||||
|
})();
|
||||||
|
|
||||||
|
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const errEl = document.getElementById('error');
|
||||||
|
errEl.style.display = 'none';
|
||||||
|
try {
|
||||||
|
await API.post('/api/login', {
|
||||||
|
username: document.getElementById('username').value,
|
||||||
|
password: document.getElementById('password').value,
|
||||||
|
});
|
||||||
|
window.location.href = '/';
|
||||||
|
} catch (err) {
|
||||||
|
errEl.textContent = err.message;
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Copies frontend libraries from node_modules into public/vendor so the app
|
||||||
|
// loads no remote scripts (works on a locked-down network, reproducible).
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, '..');
|
||||||
|
const OUT = path.join(ROOT, 'public', 'vendor');
|
||||||
|
|
||||||
|
const FILES = [
|
||||||
|
['beercss/dist/cdn/beer.min.css', 'beer.min.css'],
|
||||||
|
['beercss/dist/cdn/beer.min.js', 'beer.min.js'],
|
||||||
|
['material-dynamic-colors/dist/cdn/material-dynamic-colors.min.js', 'material-dynamic-colors.min.js'],
|
||||||
|
['alpinejs/dist/cdn.min.js', 'alpine.min.js'],
|
||||||
|
];
|
||||||
|
|
||||||
|
fs.mkdirSync(OUT, { recursive: true });
|
||||||
|
|
||||||
|
let ok = 0;
|
||||||
|
for (const [src, dest] of FILES) {
|
||||||
|
const from = path.join(ROOT, 'node_modules', src);
|
||||||
|
const to = path.join(OUT, dest);
|
||||||
|
try {
|
||||||
|
fs.copyFileSync(from, to);
|
||||||
|
console.log(`vendored ${dest}`);
|
||||||
|
ok++;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`FAILED to vendor ${src}: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`\n${ok}/${FILES.length} files vendored into public/vendor`);
|
||||||
|
if (ok < FILES.length) process.exitCode = 1;
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const express = require('express');
|
||||||
|
const helmet = require('helmet');
|
||||||
|
const session = require('express-session');
|
||||||
|
const SqliteStore = require('better-sqlite3-session-store')(session);
|
||||||
|
|
||||||
|
const db = require('./db'); // applies schema + first-run seed
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = Number(process.env.PORT) || 3000;
|
||||||
|
const PROD = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
|
app.set('trust proxy', 1);
|
||||||
|
|
||||||
|
// Security headers. CSP is relaxed for inline bootstrapping but blocks remote
|
||||||
|
// scripts; all libraries are served from our own /vendor.
|
||||||
|
app.use(
|
||||||
|
helmet({
|
||||||
|
contentSecurityPolicy: {
|
||||||
|
directives: {
|
||||||
|
defaultSrc: ["'self'"],
|
||||||
|
scriptSrc: ["'self'", "'unsafe-inline'"],
|
||||||
|
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||||
|
imgSrc: ["'self'", 'data:'],
|
||||||
|
fontSrc: ["'self'", 'data:'],
|
||||||
|
connectSrc: ["'self'"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
app.use(express.json({ limit: '256kb' }));
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
session({
|
||||||
|
store: new SqliteStore({ client: db, expired: { clear: true, intervalMs: 900000 } }),
|
||||||
|
secret: process.env.SESSION_SECRET || 'dev-insecure-secret-change-me',
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
cookie: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: PROD,
|
||||||
|
maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// API routes
|
||||||
|
app.use('/api', require('./src/routes/auth.routes'));
|
||||||
|
app.use('/api/users', require('./src/routes/users.routes'));
|
||||||
|
app.use('/api/schemes', require('./src/routes/schemes.routes'));
|
||||||
|
app.use('/api/chart', require('./src/routes/chart.routes'));
|
||||||
|
app.use('/api/projects', require('./src/routes/projects.routes'));
|
||||||
|
app.use('/api/kpis', require('./src/routes/kpis.routes'));
|
||||||
|
app.use('/api/entries', require('./src/routes/entries.routes'));
|
||||||
|
|
||||||
|
// Static frontend
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
|
// JSON 404 for unmatched API routes
|
||||||
|
app.use('/api', (req, res) => res.status(404).json({ error: 'Not found' }));
|
||||||
|
|
||||||
|
// Centralized error handler
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Bowler Chart running at http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const db = require('../db');
|
||||||
|
|
||||||
|
const getByUsername = db.prepare('SELECT * FROM users WHERE username = ?');
|
||||||
|
const getById = db.prepare('SELECT * FROM users WHERE id = ?');
|
||||||
|
|
||||||
|
/** Strip secret fields before sending a user to the client. */
|
||||||
|
function publicUser(row) {
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
username: row.username,
|
||||||
|
email: row.email,
|
||||||
|
role: row.role,
|
||||||
|
preferred_scheme_id: row.preferred_scheme_id,
|
||||||
|
active: row.active,
|
||||||
|
created_at: row.created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verify credentials; returns the user row on success, else null. */
|
||||||
|
function verifyCredentials(username, password) {
|
||||||
|
const user = getByUsername.get(username);
|
||||||
|
if (!user || !user.active) return null;
|
||||||
|
if (!bcrypt.compareSync(password, user.password_hash)) return null;
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashPassword(plain) {
|
||||||
|
return bcrypt.hashSync(plain, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Issue (once per session) a CSRF token used for double-submit verification. */
|
||||||
|
function ensureCsrfToken(req) {
|
||||||
|
if (!req.session.csrfToken) {
|
||||||
|
req.session.csrfToken = crypto.randomBytes(24).toString('hex');
|
||||||
|
}
|
||||||
|
return req.session.csrfToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
publicUser,
|
||||||
|
verifyCredentials,
|
||||||
|
hashPassword,
|
||||||
|
ensureCsrfToken,
|
||||||
|
getUserById: (id) => getById.get(id),
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Double-submit CSRF check for state-changing requests. The token is issued in
|
||||||
|
* the session (see auth.ensureCsrfToken) and surfaced to the client via
|
||||||
|
* GET /api/me; the client echoes it back in the X-CSRF-Token header.
|
||||||
|
*/
|
||||||
|
module.exports = function verifyCsrf(req, res, next) {
|
||||||
|
const sent = req.get('X-CSRF-Token');
|
||||||
|
const expected = req.session && req.session.csrfToken;
|
||||||
|
if (!expected || !sent || sent !== expected) {
|
||||||
|
return res.status(403).json({ error: 'Invalid or missing CSRF token' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/** Allow only the administrator through. Assumes requireAuth ran first. */
|
||||||
|
module.exports = function requireAdmin(req, res, next) {
|
||||||
|
if (!req.session || req.session.role !== 'admin') {
|
||||||
|
return res.status(403).json({ error: 'Administrator access required' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/** Reject requests without a logged-in session. */
|
||||||
|
module.exports = function requireAuth(req, res, next) {
|
||||||
|
if (!req.session || !req.session.userId) {
|
||||||
|
return res.status(401).json({ error: 'Not authenticated' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const db = require('../../db');
|
||||||
|
|
||||||
|
const projectById = db.prepare('SELECT * FROM projects WHERE id = ?');
|
||||||
|
const kpiById = db.prepare('SELECT * FROM kpis WHERE id = ?');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory producing middleware that loads a resource, resolves its owning
|
||||||
|
* project, and ensures the session user owns it (or is admin). The resolved
|
||||||
|
* project is attached to req.project for the handler.
|
||||||
|
*
|
||||||
|
* @param {(req) => number|undefined} getProjectId - resolve a project id from req.
|
||||||
|
*/
|
||||||
|
function ownerOfProject(getProjectId) {
|
||||||
|
return function (req, res, next) {
|
||||||
|
const projectId = getProjectId(req);
|
||||||
|
if (!projectId) return res.status(400).json({ error: 'Missing project id' });
|
||||||
|
|
||||||
|
const project = projectById.get(projectId);
|
||||||
|
if (!project) return res.status(404).json({ error: 'Project not found' });
|
||||||
|
|
||||||
|
if (req.session.role !== 'admin' && project.owner_id !== req.session.userId) {
|
||||||
|
return res.status(403).json({ error: 'You can only modify your own projects' });
|
||||||
|
}
|
||||||
|
req.project = project;
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve the owning project of a KPI referenced by req (returns project id). */
|
||||||
|
function projectIdOfKpi(kpiId) {
|
||||||
|
const kpi = kpiById.get(kpiId);
|
||||||
|
return kpi ? kpi.project_id : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { ownerOfProject, projectIdOfKpi };
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const db = require('../../db');
|
||||||
|
const auth = require('../auth');
|
||||||
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
|
const verifyCsrf = require('../middleware/csrf');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const schemeExists = db.prepare('SELECT id FROM color_schemes WHERE id = ?');
|
||||||
|
const setScheme = db.prepare('UPDATE users SET preferred_scheme_id = ? WHERE id = ?');
|
||||||
|
|
||||||
|
// POST /api/login
|
||||||
|
router.post('/login', (req, res) => {
|
||||||
|
const { username, password } = req.body || {};
|
||||||
|
if (!username || !password) {
|
||||||
|
return res.status(400).json({ error: 'Username and password are required' });
|
||||||
|
}
|
||||||
|
const user = auth.verifyCredentials(username, password);
|
||||||
|
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
|
||||||
|
// Prevent session fixation: regenerate before storing identity.
|
||||||
|
req.session.regenerate((err) => {
|
||||||
|
if (err) return res.status(500).json({ error: 'Login failed' });
|
||||||
|
req.session.userId = user.id;
|
||||||
|
req.session.role = user.role;
|
||||||
|
auth.ensureCsrfToken(req);
|
||||||
|
res.json({ user: auth.publicUser(user), csrfToken: req.session.csrfToken });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/logout
|
||||||
|
router.post('/logout', requireAuth, verifyCsrf, (req, res) => {
|
||||||
|
req.session.destroy(() => {
|
||||||
|
res.clearCookie('connect.sid');
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/me — current user + a fresh CSRF token for the client.
|
||||||
|
router.get('/me', requireAuth, (req, res) => {
|
||||||
|
const user = auth.getUserById(req.session.userId);
|
||||||
|
if (!user) {
|
||||||
|
return req.session.destroy(() => res.status(401).json({ error: 'Not authenticated' }));
|
||||||
|
}
|
||||||
|
const token = auth.ensureCsrfToken(req);
|
||||||
|
res.json({ user: auth.publicUser(user), csrfToken: token });
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/me/scheme — set the current user's preferred color scheme.
|
||||||
|
router.patch('/me/scheme', requireAuth, verifyCsrf, (req, res) => {
|
||||||
|
const { scheme_id } = req.body || {};
|
||||||
|
if (scheme_id !== null && !schemeExists.get(scheme_id)) {
|
||||||
|
return res.status(400).json({ error: 'Unknown color scheme' });
|
||||||
|
}
|
||||||
|
setScheme.run(scheme_id ?? null, req.session.userId);
|
||||||
|
res.json({ ok: true, preferred_scheme_id: scheme_id ?? null });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const db = require('../../db');
|
||||||
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const allProjects = db.prepare(
|
||||||
|
`SELECT p.*, u.username AS owner_name
|
||||||
|
FROM projects p JOIN users u ON u.id = p.owner_id
|
||||||
|
ORDER BY p.sort_order, p.name COLLATE NOCASE`
|
||||||
|
);
|
||||||
|
const kpisForProject = db.prepare(
|
||||||
|
'SELECT * FROM kpis WHERE project_id = ? ORDER BY sort_order, name COLLATE NOCASE'
|
||||||
|
);
|
||||||
|
const entriesForKpiYear = db.prepare(
|
||||||
|
'SELECT month, value, status, note FROM entries WHERE kpi_id = ? AND year = ?'
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /api/chart?year=YYYY — full nested tree, visible to any logged-in user.
|
||||||
|
router.get('/', requireAuth, (req, res) => {
|
||||||
|
const year = Number(req.query.year) || new Date().getFullYear();
|
||||||
|
|
||||||
|
const projects = allProjects.all().map((p) => {
|
||||||
|
const kpis = kpisForProject.all(p.id).map((k) => {
|
||||||
|
const byMonth = {};
|
||||||
|
for (const e of entriesForKpiYear.all(k.id, year)) byMonth[e.month] = e;
|
||||||
|
const months = Array.from({ length: 12 }, (_, i) => {
|
||||||
|
const m = byMonth[i + 1];
|
||||||
|
return {
|
||||||
|
month: i + 1,
|
||||||
|
value: m ? m.value : null,
|
||||||
|
status: m ? m.status : 'none',
|
||||||
|
note: m ? m.note : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: k.id,
|
||||||
|
name: k.name,
|
||||||
|
description: k.description,
|
||||||
|
unit: k.unit,
|
||||||
|
goal_text: k.goal_text,
|
||||||
|
months,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
description: p.description,
|
||||||
|
owner_id: p.owner_id,
|
||||||
|
owner_name: p.owner_name,
|
||||||
|
can_edit: req.session.role === 'admin' || p.owner_id === req.session.userId,
|
||||||
|
kpis,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ year, projects });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const db = require('../../db');
|
||||||
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
|
const verifyCsrf = require('../middleware/csrf');
|
||||||
|
const { ownerOfProject, projectIdOfKpi } = require('../middleware/requireOwnerOrAdmin');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
const STATUSES = new Set(['green', 'amber', 'red', 'none']);
|
||||||
|
|
||||||
|
const upsert = db.prepare(
|
||||||
|
`INSERT INTO entries (kpi_id, year, month, value, status, note, updated_by, updated_at)
|
||||||
|
VALUES (@kpi_id, @year, @month, @value, @status, @note, @updated_by, datetime('now'))
|
||||||
|
ON CONFLICT (kpi_id, year, month) DO UPDATE SET
|
||||||
|
value = excluded.value,
|
||||||
|
status = excluded.status,
|
||||||
|
note = excluded.note,
|
||||||
|
updated_by = excluded.updated_by,
|
||||||
|
updated_at = datetime('now')`
|
||||||
|
);
|
||||||
|
const getEntry = db.prepare(
|
||||||
|
'SELECT month, value, status, note FROM entries WHERE kpi_id = ? AND year = ? AND month = ?'
|
||||||
|
);
|
||||||
|
|
||||||
|
// PUT /api/entries — upsert one monthly cell; authorized via the KPI's project.
|
||||||
|
router.put(
|
||||||
|
'/',
|
||||||
|
verifyCsrf,
|
||||||
|
ownerOfProject((req) => projectIdOfKpi(Number(req.body && req.body.kpi_id))),
|
||||||
|
(req, res) => {
|
||||||
|
const { kpi_id, year, month, value, status, note } = req.body || {};
|
||||||
|
const m = Number(month);
|
||||||
|
const y = Number(year);
|
||||||
|
if (!(m >= 1 && m <= 12) || !y) {
|
||||||
|
return res.status(400).json({ error: 'Valid year and month (1-12) are required' });
|
||||||
|
}
|
||||||
|
const safeStatus = STATUSES.has(status) ? status : 'none';
|
||||||
|
upsert.run({
|
||||||
|
kpi_id: Number(kpi_id),
|
||||||
|
year: y,
|
||||||
|
month: m,
|
||||||
|
value: value === '' || value === undefined ? null : String(value),
|
||||||
|
status: safeStatus,
|
||||||
|
note: note ? String(note) : null,
|
||||||
|
updated_by: req.session.userId,
|
||||||
|
});
|
||||||
|
res.json({ entry: getEntry.get(Number(kpi_id), y, m) });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const db = require('../../db');
|
||||||
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
|
const verifyCsrf = require('../middleware/csrf');
|
||||||
|
const { ownerOfProject, projectIdOfKpi } = require('../middleware/requireOwnerOrAdmin');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
const getKpi = db.prepare('SELECT * FROM kpis WHERE id = ?');
|
||||||
|
const insertKpi = db.prepare(
|
||||||
|
`INSERT INTO kpis (project_id, name, description, unit, goal_text, sort_order)
|
||||||
|
VALUES (@project_id, @name, @description, @unit, @goal_text, @sort_order)`
|
||||||
|
);
|
||||||
|
const nextSort = db.prepare(
|
||||||
|
'SELECT COALESCE(MAX(sort_order), -1) + 1 AS n FROM kpis WHERE project_id = ?'
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/kpis — body.project_id authorizes against the owning project.
|
||||||
|
router.post(
|
||||||
|
'/',
|
||||||
|
verifyCsrf,
|
||||||
|
ownerOfProject((req) => Number(req.body && req.body.project_id)),
|
||||||
|
(req, res) => {
|
||||||
|
const name = (req.body.name || '').trim();
|
||||||
|
if (!name) return res.status(400).json({ error: 'KPI name is required' });
|
||||||
|
const info = insertKpi.run({
|
||||||
|
project_id: req.project.id,
|
||||||
|
name,
|
||||||
|
description: (req.body.description || '').trim() || null,
|
||||||
|
unit: (req.body.unit || '').trim() || null,
|
||||||
|
goal_text: (req.body.goal_text || '').trim() || null,
|
||||||
|
sort_order: nextSort.get(req.project.id).n,
|
||||||
|
});
|
||||||
|
res.status(201).json({ kpi: getKpi.get(info.lastInsertRowid) });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Authorize :id routes via the KPI's owning project.
|
||||||
|
const kpiProject = (req) => projectIdOfKpi(Number(req.params.id));
|
||||||
|
|
||||||
|
// PATCH /api/kpis/:id
|
||||||
|
router.patch('/:id', verifyCsrf, ownerOfProject(kpiProject), (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const kpi = getKpi.get(id);
|
||||||
|
const { name, description, unit, goal_text, sort_order } = req.body || {};
|
||||||
|
const next = {
|
||||||
|
name: name !== undefined ? (name.trim() || kpi.name) : kpi.name,
|
||||||
|
description: description !== undefined ? (description || null) : kpi.description,
|
||||||
|
unit: unit !== undefined ? (unit || null) : kpi.unit,
|
||||||
|
goal_text: goal_text !== undefined ? (goal_text || null) : kpi.goal_text,
|
||||||
|
sort_order: sort_order !== undefined ? Number(sort_order) : kpi.sort_order,
|
||||||
|
id,
|
||||||
|
};
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE kpis SET name=@name, description=@description, unit=@unit,
|
||||||
|
goal_text=@goal_text, sort_order=@sort_order WHERE id=@id`
|
||||||
|
).run(next);
|
||||||
|
res.json({ kpi: getKpi.get(id) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/kpis/:id (cascades to entries)
|
||||||
|
router.delete('/:id', verifyCsrf, ownerOfProject(kpiProject), (req, res) => {
|
||||||
|
db.prepare('DELETE FROM kpis WHERE id = ?').run(Number(req.params.id));
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const db = require('../../db');
|
||||||
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
|
const verifyCsrf = require('../middleware/csrf');
|
||||||
|
const { ownerOfProject } = require('../middleware/requireOwnerOrAdmin');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
const getProject = db.prepare('SELECT * FROM projects WHERE id = ?');
|
||||||
|
const insertProject = db.prepare(
|
||||||
|
`INSERT INTO projects (name, description, owner_id, sort_order)
|
||||||
|
VALUES (@name, @description, @owner_id, @sort_order)`
|
||||||
|
);
|
||||||
|
const nextSort = db.prepare(
|
||||||
|
'SELECT COALESCE(MAX(sort_order), -1) + 1 AS n FROM projects WHERE owner_id = ?'
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /api/projects — create a project owned by the current user.
|
||||||
|
router.post('/', verifyCsrf, (req, res) => {
|
||||||
|
const name = (req.body && req.body.name || '').trim();
|
||||||
|
if (!name) return res.status(400).json({ error: 'Project name is required' });
|
||||||
|
const info = insertProject.run({
|
||||||
|
name,
|
||||||
|
description: (req.body.description || '').trim() || null,
|
||||||
|
owner_id: req.session.userId,
|
||||||
|
sort_order: nextSort.get(req.session.userId).n,
|
||||||
|
});
|
||||||
|
res.status(201).json({ project: getProject.get(info.lastInsertRowid) });
|
||||||
|
});
|
||||||
|
|
||||||
|
const fromParam = (req) => Number(req.params.id);
|
||||||
|
|
||||||
|
// PATCH /api/projects/:id
|
||||||
|
router.patch('/:id', verifyCsrf, ownerOfProject(fromParam), (req, res) => {
|
||||||
|
const { name, description, sort_order } = req.body || {};
|
||||||
|
const next = {
|
||||||
|
name: name !== undefined ? (name.trim() || req.project.name) : req.project.name,
|
||||||
|
description: description !== undefined ? (description || null) : req.project.description,
|
||||||
|
sort_order: sort_order !== undefined ? Number(sort_order) : req.project.sort_order,
|
||||||
|
id: req.project.id,
|
||||||
|
};
|
||||||
|
db.prepare(
|
||||||
|
'UPDATE projects SET name=@name, description=@description, sort_order=@sort_order WHERE id=@id'
|
||||||
|
).run(next);
|
||||||
|
res.json({ project: getProject.get(req.project.id) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/projects/:id (cascades to kpis + entries)
|
||||||
|
router.delete('/:id', verifyCsrf, ownerOfProject(fromParam), (req, res) => {
|
||||||
|
db.prepare('DELETE FROM projects WHERE id = ?').run(req.project.id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const db = require('../../db');
|
||||||
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
|
const requireAdmin = require('../middleware/requireAdmin');
|
||||||
|
const verifyCsrf = require('../middleware/csrf');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const listSchemes = db.prepare('SELECT * FROM color_schemes ORDER BY is_default DESC, name COLLATE NOCASE');
|
||||||
|
const getScheme = db.prepare('SELECT * FROM color_schemes WHERE id = ?');
|
||||||
|
const clearDefault = db.prepare('UPDATE color_schemes SET is_default = 0');
|
||||||
|
const insertScheme = db.prepare(
|
||||||
|
`INSERT INTO color_schemes (name, seed_color, mode, is_default)
|
||||||
|
VALUES (@name, @seed_color, @mode, @is_default)`
|
||||||
|
);
|
||||||
|
const countSchemes = db.prepare('SELECT COUNT(*) AS n FROM color_schemes');
|
||||||
|
|
||||||
|
const HEX = /^#[0-9a-fA-F]{6}$/;
|
||||||
|
|
||||||
|
// GET /api/schemes is readable by any logged-in user (the picker needs it);
|
||||||
|
// writes are admin-only.
|
||||||
|
router.get('/', requireAuth, (req, res) => res.json({ schemes: listSchemes.all() }));
|
||||||
|
|
||||||
|
router.use(requireAuth, requireAdmin);
|
||||||
|
|
||||||
|
function validate(body) {
|
||||||
|
const name = (body.name || '').trim();
|
||||||
|
const seed = (body.seed_color || '').trim();
|
||||||
|
const mode = body.mode === 'dark' ? 'dark' : 'light';
|
||||||
|
if (!name) return { error: 'Name is required' };
|
||||||
|
if (!HEX.test(seed)) return { error: 'Seed color must be a #rrggbb hex value' };
|
||||||
|
return { value: { name, seed_color: seed, mode } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/schemes
|
||||||
|
router.post('/', verifyCsrf, (req, res) => {
|
||||||
|
const { error, value } = validate(req.body || {});
|
||||||
|
if (error) return res.status(400).json({ error });
|
||||||
|
|
||||||
|
const makeDefault = req.body.is_default ? 1 : countSchemes.get().n === 0 ? 1 : 0;
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
if (makeDefault) clearDefault.run();
|
||||||
|
const info = insertScheme.run({ ...value, is_default: makeDefault });
|
||||||
|
return getScheme.get(info.lastInsertRowid);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
res.status(201).json({ scheme: tx() });
|
||||||
|
} catch (e) {
|
||||||
|
if (String(e.message).includes('UNIQUE')) {
|
||||||
|
return res.status(409).json({ error: 'A scheme with that name already exists' });
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/schemes/:id
|
||||||
|
router.patch('/:id', verifyCsrf, (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const scheme = getScheme.get(id);
|
||||||
|
if (!scheme) return res.status(404).json({ error: 'Scheme not found' });
|
||||||
|
|
||||||
|
const { error, value } = validate({ ...scheme, ...req.body });
|
||||||
|
if (error) return res.status(400).json({ error });
|
||||||
|
const makeDefault = req.body.is_default ? 1 : scheme.is_default;
|
||||||
|
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
if (req.body.is_default) clearDefault.run();
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE color_schemes SET name=@name, seed_color=@seed_color, mode=@mode, is_default=@is_default
|
||||||
|
WHERE id=@id`
|
||||||
|
).run({ ...value, is_default: makeDefault, id });
|
||||||
|
return getScheme.get(id);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
res.json({ scheme: tx() });
|
||||||
|
} catch (e) {
|
||||||
|
if (String(e.message).includes('UNIQUE')) {
|
||||||
|
return res.status(409).json({ error: 'A scheme with that name already exists' });
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/schemes/:id
|
||||||
|
router.delete('/:id', verifyCsrf, (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const scheme = getScheme.get(id);
|
||||||
|
if (!scheme) return res.status(404).json({ error: 'Scheme not found' });
|
||||||
|
if (scheme.is_default) {
|
||||||
|
return res.status(409).json({ error: 'Cannot delete the default scheme; set another default first' });
|
||||||
|
}
|
||||||
|
// Users referencing it fall back to default via ON DELETE SET NULL.
|
||||||
|
db.prepare('DELETE FROM color_schemes WHERE id = ?').run(id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const db = require('../../db');
|
||||||
|
const auth = require('../auth');
|
||||||
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
|
const requireAdmin = require('../middleware/requireAdmin');
|
||||||
|
const verifyCsrf = require('../middleware/csrf');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Every route here is admin-only and state-changing routes require CSRF.
|
||||||
|
router.use(requireAuth, requireAdmin);
|
||||||
|
|
||||||
|
const listUsers = db.prepare(
|
||||||
|
`SELECT id, username, email, role, preferred_scheme_id, active, created_at
|
||||||
|
FROM users ORDER BY username COLLATE NOCASE`
|
||||||
|
);
|
||||||
|
const getUser = db.prepare('SELECT * FROM users WHERE id = ?');
|
||||||
|
const insertUser = db.prepare(
|
||||||
|
`INSERT INTO users (username, email, password_hash, role, active)
|
||||||
|
VALUES (@username, @email, @hash, @role, @active)`
|
||||||
|
);
|
||||||
|
const countAdmins = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin' AND active = 1");
|
||||||
|
|
||||||
|
// GET /api/users
|
||||||
|
router.get('/', (req, res) => res.json({ users: listUsers.all() }));
|
||||||
|
|
||||||
|
// POST /api/users
|
||||||
|
router.post('/', verifyCsrf, (req, res) => {
|
||||||
|
const { username, email, password, role, active } = req.body || {};
|
||||||
|
if (!username || !password) {
|
||||||
|
return res.status(400).json({ error: 'Username and password are required' });
|
||||||
|
}
|
||||||
|
const safeRole = role === 'admin' ? 'admin' : 'user';
|
||||||
|
try {
|
||||||
|
const info = insertUser.run({
|
||||||
|
username: username.trim(),
|
||||||
|
email: email || null,
|
||||||
|
hash: auth.hashPassword(password),
|
||||||
|
role: safeRole,
|
||||||
|
active: active === false ? 0 : 1,
|
||||||
|
});
|
||||||
|
res.status(201).json({ user: auth.publicUser(getUser.get(info.lastInsertRowid)) });
|
||||||
|
} catch (e) {
|
||||||
|
if (String(e.message).includes('UNIQUE')) {
|
||||||
|
return res.status(409).json({ error: 'Username already exists' });
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/users/:id — update email/role/active and optionally reset password.
|
||||||
|
router.patch('/:id', verifyCsrf, (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const user = getUser.get(id);
|
||||||
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||||
|
|
||||||
|
const { email, role, active, password } = req.body || {};
|
||||||
|
const next = {
|
||||||
|
email: email !== undefined ? email || null : user.email,
|
||||||
|
role: role === 'admin' || role === 'user' ? role : user.role,
|
||||||
|
active: active !== undefined ? (active ? 1 : 0) : user.active,
|
||||||
|
hash: password ? auth.hashPassword(password) : user.password_hash,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Never let the last active admin demote or deactivate themselves out of access.
|
||||||
|
const losingAdmin =
|
||||||
|
user.role === 'admin' && user.active && (next.role !== 'admin' || next.active === 0);
|
||||||
|
if (losingAdmin && countAdmins.get().n <= 1) {
|
||||||
|
return res.status(409).json({ error: 'Cannot remove the last active administrator' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE users SET email = @email, role = @role, active = @active, password_hash = @hash
|
||||||
|
WHERE id = @id`
|
||||||
|
).run({ ...next, id });
|
||||||
|
res.json({ user: auth.publicUser(getUser.get(id)) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/users/:id
|
||||||
|
router.delete('/:id', verifyCsrf, (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const user = getUser.get(id);
|
||||||
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||||
|
if (id === req.session.userId) {
|
||||||
|
return res.status(409).json({ error: 'You cannot delete your own account' });
|
||||||
|
}
|
||||||
|
if (user.role === 'admin' && user.active && countAdmins.get().n <= 1) {
|
||||||
|
return res.status(409).json({ error: 'Cannot delete the last active administrator' });
|
||||||
|
}
|
||||||
|
db.prepare('DELETE FROM users WHERE id = ?').run(id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
Loading…
Reference in New Issue