security hardening + drafts/attachments

This commit is contained in:
2026-02-21 19:10:56 -05:00
parent 1dc99eb681
commit a0105956e4
35 changed files with 4928 additions and 0 deletions

27
.env.example Normal file
View File

@@ -0,0 +1,27 @@
# Core
DB_PASSWORD=mastermind
DATABASE_URL=postgres://postgres:${DB_PASSWORD}@db:5432/mastermind
# Public base URL (set to https://... when live)
BASE_URL=http://100.101.78.42:3005
# REQUIRED in production (>=24 chars). Generate with: openssl rand -base64 48
SESSION_SECRET=change-this-to-a-long-random-string
# If running behind a reverse proxy (Traefik/Nginx/Caddy), enable this so req.ip and secure cookies work
TRUST_PROXY=true
# If public site is HTTPS, set COOKIE_SECURE=true (or just make BASE_URL https://...)
COOKIE_SECURE=false
# One-time bootstrap local owner (only used if there are no local identities yet)
BOOTSTRAP_OWNER_EMAIL=
BOOTSTRAP_OWNER_PASSWORD=
# Google OAuth (optional)
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Microsoft OAuth (optional)
MICROSOFT_CLIENT_ID=
MICROSOFT_CLIENT_SECRET=

26
.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
.env
.env.*
!.env.example
# node
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# data
/data/
web/data/
worker/data/
# uploads/attachments
**/uploads/
**/attachments/
# OS
.DS_Store
# misc
*.swp

10
CHANGELOG.md Normal file
View File

@@ -0,0 +1,10 @@
# Changelog
## Unreleased
- Initial MVP scaffold
- Local/Google/Microsoft auth
- Owner user management + password change
- Audit logs + viewer
- Projects module
- Inbox import (.eml) + triage + rules-based auto-assign
- Email connectors + rules UI (placeholders until OAuth)

38
DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,38 @@
# DEVELOPMENT — Mastermind MVP
## Web app
- Entry point: `web/src/index.js`
- Views: `web/src/views/*.ejs`
## Database
Schema is created/altered on startup (MVP style) in `ensureSchema()`.
Tables of interest:
- `users`, `identities` — auth + multi-provider identities
- `audit_logs` — append-only audit trail
- `projects`, `project_members` — project profiles + membership
- `ingested_emails` — unified inbox store (upload now, OAuth later)
- `email_connectors` — gmail/microsoft status rows
- `email_rules` — sorting/assignment rules
## Audit logging convention
Use:
```js
await audit(req, 'namespace.action', { targetType, targetId, metadata })
```
Examples:
- `auth.login_success`
- `admin.user_created`
- `project.created`
- `inbox.email_imported`
## Adding a new feature (pattern)
1) Add DB table/column in `ensureSchema()`
2) Add routes in `web/src/index.js`
3) Add views in `web/src/views/`
4) Log all state changes to `audit_logs`
## Worker
- `worker/src/worker.js` is a placeholder loop.
- Later it will pull from connectors, OCR, classify, and run rule assignment.

53
INSTALL.md Normal file
View File

@@ -0,0 +1,53 @@
# INSTALL — Mastermind MVP
## Prerequisites
- Docker + Docker Compose
- A stable hostname/IP you can reach (LAN or Tailscale)
## Configure
```bash
cd /root/clawd/mastermind-mvp
cp .env.example .env
```
Edit `.env`:
- `SESSION_SECRET` — set to a long random value
- `BASE_URL` — the URL you use to reach the app (Tailscale IP recommended)
OAuth is optional for now:
- `GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET`
- `MICROSOFT_CLIENT_ID/MICROSOFT_CLIENT_SECRET`
## Start
```bash
docker compose up -d --build
```
App:
- http://<BASE_URL_HOST>:3005/login
## First login (important)
On first run the app creates:
- `owner@local` / `owner`
Immediately change it:
- http://<host>:3005/account/password
## Create your first project
- http://<host>:3005/projects
## Import email (until OAuth is connected)
Export emails as `.eml` and upload:
- http://<host>:3005/inbox
## Enable connector placeholders
- http://<host>:3005/admin/email
Connectors will show **Configured: no** until OAuth client credentials are added.
## OAuth callbacks (when youre ready)
These are the callback URLs the app expects:
- Google: `BASE_URL/auth/google/callback`
- Microsoft: `BASE_URL/auth/microsoft/callback`
Set `BASE_URL` correctly before authorizing.

59
OPERATIONS.md Normal file
View File

@@ -0,0 +1,59 @@
# OPERATIONS — Mastermind MVP
## Logs
```bash
cd /root/clawd/mastermind-mvp
docker compose logs -f web
docker compose logs -f db
```
## Restart
```bash
docker compose restart
```
## Update (pull latest code and rebuild)
```bash
docker compose down
docker compose up -d --build
```
## Data persistence
Data is stored under `./data/`:
- `data/postgres/` — Postgres volume
- `data/uploads/` — stored imported `.eml` files
## Backup (manual for now)
```bash
# Stop app for a consistent snapshot
docker compose down
tar -czf mastermind_backup_$(date +%Y%m%d_%H%M%S).tar.gz data .env docker-compose.yml
# Start again
docker compose up -d --build
```
## Restore
```bash
docker compose down
rm -rf data
tar -xzf mastermind_backup_YYYYMMDD_HHMMSS.tar.gz
docker compose up -d --build
```
## Move to a new server (portability)
Copy:
- the whole `mastermind-mvp/` folder (or at minimum: `docker-compose.yml`, `web/`, `worker/`, `data/`, `.env`)
On new host:
```bash
cd mastermind-mvp
docker compose up -d --build
```
Then verify:
- `/health`
- login works

48
docker-compose.yml Normal file
View File

@@ -0,0 +1,48 @@
services:
db:
image: postgres:16
environment:
POSTGRES_DB: mastermind
POSTGRES_USER: mastermind
POSTGRES_PASSWORD: ${DB_PASSWORD:-mastermind}
volumes:
- ./data/postgres:/var/lib/postgresql/data
ports:
- "5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U mastermind -d mastermind"]
interval: 5s
timeout: 5s
retries: 20
web:
build: ./web
environment:
NODE_ENV: development
PORT: 3005
DATABASE_URL: postgres://mastermind:${DB_PASSWORD:-mastermind}@db:5432/mastermind
SESSION_SECRET: ${SESSION_SECRET:-change-me}
BASE_URL: ${BASE_URL:-http://localhost:3005}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
MICROSOFT_CLIENT_ID: ${MICROSOFT_CLIENT_ID:-}
MICROSOFT_CLIENT_SECRET: ${MICROSOFT_CLIENT_SECRET:-}
depends_on:
db:
condition: service_healthy
ports:
- "3005:3005"
volumes:
- ./data:/app/data
worker:
build: ./worker
environment:
NODE_ENV: development
DATABASE_URL: postgres://mastermind:${DB_PASSWORD:-mastermind}@db:5432/mastermind
depends_on:
db:
condition: service_healthy
# (MVP) no bind-mount to avoid masking node_modules inside container
volumes: {}

13
package.json Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "mastermind-mvp",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs"
}

27
scripts/backup.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
HERE=$(cd "$(dirname "$0")/.." && pwd)
TS=$(date +%Y%m%d_%H%M%S)
OUT="$HERE/backups"
mkdir -p "$OUT"
ARCHIVE="$OUT/mastermind_backup_${TS}.tar.gz"
echo "[backup] stopping containers for consistent snapshot..."
cd "$HERE"
docker compose down
echo "[backup] creating archive: $ARCHIVE"
tar -czf "$ARCHIVE" \
data \
.env \
docker-compose.yml \
README.md INSTALL.md OPERATIONS.md DEVELOPMENT.md CHANGELOG.md \
web worker scripts \
2>/dev/null || true
echo "[backup] starting containers..."
docker compose up -d --build
echo "[backup] done: $ARCHIVE"

26
scripts/restore.sh Executable file
View File

@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
if [ $# -ne 1 ]; then
echo "Usage: $0 <backup-archive.tar.gz>"
exit 1
fi
ARCHIVE="$1"
HERE=$(cd "$(dirname "$0")/.." && pwd)
cd "$HERE"
echo "[restore] stopping containers..."
docker compose down
echo "[restore] wiping data/..."
rm -rf data
echo "[restore] extracting $ARCHIVE"
tar -xzf "$ARCHIVE" -C "$HERE"
echo "[restore] starting containers..."
docker compose up -d --build
echo "[restore] done"

7
web/Dockerfile Normal file
View File

@@ -0,0 +1,7 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 3005
CMD ["npm","run","dev"]

BIN
web/core Normal file

Binary file not shown.

1825
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
web/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "mastermind-web",
"private": true,
"type": "module",
"scripts": {
"dev": "node --watch src/index.js",
"start": "node src/index.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"connect-pg-simple": "^10.0.0",
"dotenv": "^16.4.5",
"ejs": "^3.1.10",
"express": "^4.19.2",
"express-rate-limit": "^7.4.0",
"express-session": "^1.18.1",
"helmet": "^7.1.0",
"mailparser": "^3.7.2",
"multer": "^1.4.5-lts.1",
"marked": "^12.0.2",
"markdown-it": "^14.1.0",
"passport": "^0.7.0",
"passport-azure-ad-oauth2": "^0.0.4",
"passport-google-oauth20": "^2.0.0",
"passport-local": "^1.0.0",
"pg": "^8.12.0"
}
}

1284
web/src/index.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,45 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Mastermind — Change Password</title>
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:0;background:#0b0f14;color:#e8eef7}
header{background:#111824;border-bottom:1px solid #233043;padding:14px 16px;position:sticky;top:0}
main{max-width:700px;margin:0 auto;padding:16px}
.card{background:#111824;border:1px solid #233043;border-radius:12px;padding:14px;margin:12px 0}
label{display:block;margin:10px 0 6px;color:#a9b7c6}
input{width:100%;padding:12px;border-radius:10px;border:1px solid #233043;background:#0e1520;color:#e8eef7}
button{padding:12px 14px;border-radius:10px;border:1px solid #233043;background:#1c2a3d;color:#e8eef7;margin-top:12px;width:100%}
a{color:#7cc4ff;text-decoration:none}
.muted{color:#a9b7c6;font-size:13px;line-height:1.4}
</style>
</head>
<body>
<header>
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px">
<div style="font-weight:700">Change Password</div>
<div style="display:flex;gap:10px">
<a href="/">Dashboard</a>
<a href="/logout">Logout</a>
</div>
</div>
</header>
<main>
<div class="card">
<p class="muted">This changes your <b>local</b> password (SSO passwords are managed by Google/Microsoft). If you dont have a local identity yet, this will create one.</p>
<form method="post" action="/account/password">
<label>Current password (leave blank if you have no local password yet)</label>
<input name="currentPassword" type="password" />
<label>New password</label>
<input name="newPassword" type="password" required />
<label>Confirm new password</label>
<input name="confirmPassword" type="password" required />
<button type="submit">Update password</button>
</form>
</div>
</main>
</body>
</html>

View File

@@ -0,0 +1,64 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Mastermind — Audit Log</title>
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:0;background:#0b0f14;color:#e8eef7}
header{background:#111824;border-bottom:1px solid #233043;padding:14px 16px;position:sticky;top:0}
main{max-width:1200px;margin:0 auto;padding:16px}
.card{background:#111824;border:1px solid #233043;border-radius:12px;padding:14px;margin:12px 0}
a{color:#7cc4ff;text-decoration:none}
.muted{color:#a9b7c6}
table{width:100%;border-collapse:collapse}
th,td{border-bottom:1px solid #233043;padding:10px 6px;text-align:left;color:#a9b7c6;font-size:13px;vertical-align:top}
th{color:#e8eef7}
code{font-family:ui-monospace,Menlo,Consolas,monospace;color:#d6e3f3}
</style>
</head>
<body>
<header>
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px">
<div style="font-weight:700">Audit Log</div>
<div style="display:flex;gap:10px">
<a href="/admin/users">Users</a>
<a href="/">Dashboard</a>
<a href="/logout">Logout</a>
</div>
</div>
</header>
<main>
<div class="card">
<div class="muted">Showing latest <%= logs.length %> events (most recent first).</div>
<div style="overflow:auto;margin-top:10px">
<table>
<thead>
<tr>
<th>Time</th>
<th>Actor</th>
<th>Action</th>
<th>Target</th>
<th>IP</th>
<th>Metadata</th>
</tr>
</thead>
<tbody>
<% logs.forEach(l => { %>
<tr>
<td><%= l.created_at %></td>
<td><%= l.actor_email || '' %></td>
<td><code><%= l.action %></code></td>
<td><%= l.target_type || '' %> <%= l.target_id || '' %></td>
<td><%= l.ip || '' %></td>
<td><code><%= JSON.stringify(l.metadata || {}) %></code></td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
</main>
</body>
</html>

View File

@@ -0,0 +1,119 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Mastermind — Users</title>
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:0;background:#0b0f14;color:#e8eef7}
header{background:#111824;border-bottom:1px solid #233043;padding:14px 16px;position:sticky;top:0}
main{max-width:1100px;margin:0 auto;padding:16px}
.card{background:#111824;border:1px solid #233043;border-radius:12px;padding:14px;margin:12px 0}
a{color:#7cc4ff;text-decoration:none}
.muted{color:#a9b7c6}
table{width:100%;border-collapse:collapse}
th,td{border-bottom:1px solid #233043;padding:10px 6px;text-align:left;color:#a9b7c6;font-size:13px;vertical-align:top}
th{color:#e8eef7}
input,select{padding:10px;border-radius:10px;border:1px solid #233043;background:#0e1520;color:#e8eef7;width:100%}
button{padding:10px 12px;border-radius:10px;border:1px solid #233043;background:#1c2a3d;color:#e8eef7}
.grid{display:grid;grid-template-columns:repeat(12,1fr);gap:12px}
.col6{grid-column:span 12}
@media(min-width:900px){.col6{grid-column:span 6}}
.pill{display:inline-block;padding:2px 8px;border:1px solid #233043;border-radius:999px;font-size:12px;color:#a9b7c6}
.danger{background:#2b1216;border-color:#5a1e28}
</style>
</head>
<body>
<header>
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px">
<div style="font-weight:700">User Management</div>
<div style="display:flex;gap:10px">
<a href="/">Dashboard</a>
<a href="/logout">Logout</a>
</div>
</div>
</header>
<main>
<div class="card">
<h2 style="margin:0 0 8px">Create local user</h2>
<form method="post" action="/admin/users/create" class="grid">
<div class="col6">
<label class="muted">Email</label>
<input name="email" placeholder="user@company.com" required />
</div>
<div class="col6">
<label class="muted">Display name</label>
<input name="displayName" placeholder="Jane Doe" />
</div>
<div class="col6">
<label class="muted">Role</label>
<select name="role">
<option value="apm">apm</option>
<option value="pm">pm</option>
<option value="viewer">viewer</option>
<option value="owner">owner</option>
</select>
</div>
<div class="col6">
<label class="muted">Temporary password</label>
<input name="tempPassword" placeholder="Temp password" required />
</div>
<div style="grid-column:span 12">
<button type="submit">Create user</button>
</div>
</form>
<p class="muted" style="margin-top:10px">SSO users (Google/Microsoft) will appear here after their first login.</p>
</div>
<div class="card">
<h2 style="margin:0 0 8px">Users</h2>
<table>
<thead>
<tr>
<th>Email</th>
<th>Name</th>
<th>Role</th>
<th>Status</th>
<th>Identities</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<% users.forEach(u => { %>
<tr>
<td><%= u.email || '(no email)' %></td>
<td><%= u.display_name || '' %></td>
<td><span class="pill"><%= u.role %></span></td>
<td>
<% if (u.disabled) { %>
<span class="pill danger">disabled</span>
<% } else { %>
<span class="pill">active</span>
<% } %>
</td>
<td class="muted">
<%= (u.providers || '').split(',').filter(Boolean).join(', ') %>
</td>
<td>
<form method="post" action="/admin/users/<%= u.id %>/reset" style="display:inline-block;min-width:220px">
<input name="newPassword" placeholder="New temp password" />
<button type="submit" style="margin-top:6px;width:100%">Reset local password</button>
</form>
<div style="height:8px"></div>
<form method="post" action="/admin/users/<%= u.id %>/toggle" style="display:inline-block;width:220px">
<button type="submit" style="width:100%"><%= u.disabled ? 'Enable user' : 'Disable user' %></button>
</form>
<div style="height:8px"></div>
<form method="post" action="/admin/users/<%= u.id %>/delete" onsubmit="return confirm('Delete user? This cannot be undone.');" style="display:inline-block;width:220px">
<button type="submit" style="width:100%;background:#2b1216;border-color:#5a1e28">Delete user</button>
</form>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</main>
</body>
</html>

59
web/src/views/docs.ejs Normal file
View File

@@ -0,0 +1,59 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Mastermind — Docs</title>
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:0;background:#0b0f14;color:#e8eef7}
header{background:#111824;border-bottom:1px solid #233043;padding:14px 16px;position:sticky;top:0}
main{max-width:1100px;margin:0 auto;padding:16px}
.card{background:#111824;border:1px solid #233043;border-radius:12px;padding:14px;margin:12px 0}
a{color:#7cc4ff;text-decoration:none}
.muted{color:#a9b7c6}
.grid{display:grid;grid-template-columns:repeat(12,1fr);gap:12px}
.nav{grid-column:span 12}
.content{grid-column:span 12}
@media(min-width:900px){.nav{grid-column:span 3}.content{grid-column:span 9}}
.nav a{display:block;padding:10px;border:1px solid #233043;border-radius:10px;margin-bottom:8px;background:#0e1520}
.doc{background:#0e1520;border:1px solid #233043;border-radius:10px;padding:14px;overflow:auto}
.doc h1,.doc h2,.doc h3{color:#e8eef7}
.doc p,.doc li{color:#a9b7c6;line-height:1.45}
.doc code,.doc pre{font-family:ui-monospace,Menlo,Consolas,monospace}
.doc pre{background:#0b0f14;border:1px solid #233043;border-radius:10px;padding:12px;color:#d6e3f3;overflow:auto}
</style>
</head>
<body>
<header>
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px">
<div style="font-weight:700">Docs</div>
<div style="display:flex;gap:10px">
<a href="/">Dashboard</a>
<a href="/logout">Logout</a>
</div>
</div>
</header>
<main>
<div class="grid">
<div class="nav">
<div class="card">
<% docs.forEach(d => { %>
<a href="/docs/<%= d.slug %>"><%= d.title %></a>
<% }) %>
</div>
</div>
<div class="content">
<div class="card">
<div class="muted">Viewing: <b><%= current.title %></b></div>
<div class="muted">Source file: <code><%= current.path %></code></div>
</div>
<div class="doc">
<%- html %>
</div>
</div>
</div>
</main>
</body>
</html>

View File

@@ -0,0 +1,75 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Mastermind — Draft</title>
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:0;background:#0b0f14;color:#e8eef7}
header{background:#111824;border-bottom:1px solid #233043;padding:14px 16px;position:sticky;top:0}
main{max-width:1100px;margin:0 auto;padding:16px}
.card{background:#111824;border:1px solid #233043;border-radius:12px;padding:14px;margin:12px 0}
a{color:#7cc4ff;text-decoration:none}
.muted{color:#a9b7c6}
textarea,input,select{width:100%;padding:12px;border-radius:10px;border:1px solid #233043;background:#0e1520;color:#e8eef7}
button{padding:12px 14px;border-radius:10px;border:1px solid #233043;background:#1c2a3d;color:#e8eef7}
.grid{display:grid;grid-template-columns:repeat(12,1fr);gap:12px}
.col6{grid-column:span 12}
@media(min-width:900px){.col6{grid-column:span 6}}
pre{white-space:pre-wrap;background:#0e1520;border:1px solid #233043;border-radius:10px;padding:12px;color:#d6e3f3}
</style>
</head>
<body>
<header>
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px">
<div style="font-weight:700"><%= kind.toUpperCase() %> Draft</div>
<div style="display:flex;gap:10px">
<a href="/">Dashboard</a>
<a href="/logout">Logout</a>
</div>
</div>
</header>
<main>
<div class="card">
<div class="muted"><b>Project:</b> <%= project ? project.name : '(unassigned)' %></div>
<div class="muted"><b>Source email:</b> <% if (email) { %><a href="/inbox/<%= email.id %>"><%= email.subject || email.id %></a><% } else { %>—<% } %></div>
</div>
<div class="card">
<div style="display:flex;gap:10px;flex-wrap:wrap;justify-content:flex-end;margin-bottom:10px">
<a href="/drafts/<%= kind %>/<%= draft.id %>/export.md">Download .md</a>
</div>
<form method="post" action="/drafts/<%= kind %>/<%= draft.id %>/save" class="grid">
<div class="col6">
<label class="muted">Title</label>
<input name="title" value="<%= draft.title || '' %>" />
</div>
<div class="col6">
<label class="muted">Status</label>
<select name="status">
<% ['draft','ready','sent'].forEach(s => { %>
<option value="<%= s %>" <%= draft.status===s?'selected':'' %>><%= s %></option>
<% }) %>
</select>
</div>
<div style="grid-column:span 12">
<label class="muted">Body (Markdown)</label>
<textarea name="body" rows="14"><%= draft.body || '' %></textarea>
</div>
<div style="grid-column:span 12">
<button type="submit">Save</button>
</div>
</form>
</div>
<div class="card">
<h2 style="margin:0 0 8px">Preview</h2>
<div class="muted" style="margin-bottom:8px">Rendered Markdown preview</div>
<div style="background:#0e1520;border:1px solid #233043;border-radius:10px;padding:12px;overflow:auto">
<%- previewHtml %>
</div>
</div>
</main>
</body>
</html>

View File

@@ -0,0 +1,70 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Mastermind — Drafts</title>
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:0;background:#0b0f14;color:#e8eef7}
header{background:#111824;border-bottom:1px solid #233043;padding:14px 16px;position:sticky;top:0}
main{max-width:1200px;margin:0 auto;padding:16px}
.card{background:#111824;border:1px solid #233043;border-radius:12px;padding:14px;margin:12px 0}
a{color:#7cc4ff;text-decoration:none}
.muted{color:#a9b7c6}
table{width:100%;border-collapse:collapse}
th,td{border-bottom:1px solid #233043;padding:10px 6px;text-align:left;color:#a9b7c6;font-size:13px;vertical-align:top}
th{color:#e8eef7}
.pill{display:inline-block;padding:2px 8px;border:1px solid #233043;border-radius:999px;font-size:12px;color:#a9b7c6}
</style>
</head>
<body>
<header>
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px">
<div style="font-weight:700">Drafts</div>
<div style="display:flex;gap:10px">
<a href="/">Dashboard</a>
<a href="/logout">Logout</a>
</div>
</div>
</header>
<main>
<div class="card">
<div class="muted">PCO and RFI drafts generated from emails. Nothing is sent automatically.</div>
</div>
<div class="card">
<h2 style="margin:0 0 8px">PCO drafts</h2>
<table>
<thead><tr><th>Created</th><th>Project</th><th>Title</th><th>Status</th></tr></thead>
<tbody>
<% pcos.forEach(d => { %>
<tr>
<td><%= d.created_at %></td>
<td><%= d.project_name || '' %></td>
<td><a href="/drafts/pco/<%= d.id %>"><%= d.title || '(untitled)' %></a></td>
<td><span class="pill"><%= d.status %></span></td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<div class="card">
<h2 style="margin:0 0 8px">RFI drafts</h2>
<table>
<thead><tr><th>Created</th><th>Project</th><th>Title</th><th>Status</th></tr></thead>
<tbody>
<% rfis.forEach(d => { %>
<tr>
<td><%= d.created_at %></td>
<td><%= d.project_name || '' %></td>
<td><a href="/drafts/rfi/<%= d.id %>"><%= d.title || '(untitled)' %></a></td>
<td><span class="pill"><%= d.status %></span></td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</main>
</body>
</html>

View File

@@ -0,0 +1,110 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Mastermind — Email Rules</title>
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:0;background:#0b0f14;color:#e8eef7}
header{background:#111824;border-bottom:1px solid #233043;padding:14px 16px;position:sticky;top:0}
main{max-width:1200px;margin:0 auto;padding:16px}
.card{background:#111824;border:1px solid #233043;border-radius:12px;padding:14px;margin:12px 0}
a{color:#7cc4ff;text-decoration:none}
.muted{color:#a9b7c6}
input,select{padding:12px;border-radius:10px;border:1px solid #233043;background:#0e1520;color:#e8eef7;width:100%}
button{padding:12px 14px;border-radius:10px;border:1px solid #233043;background:#1c2a3d;color:#e8eef7}
table{width:100%;border-collapse:collapse}
th,td{border-bottom:1px solid #233043;padding:10px 6px;text-align:left;color:#a9b7c6;font-size:13px;vertical-align:top}
th{color:#e8eef7}
.grid{display:grid;grid-template-columns:repeat(12,1fr);gap:12px}
.col4{grid-column:span 12}
@media(min-width:900px){.col4{grid-column:span 4}}
</style>
</head>
<body>
<header>
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px">
<div style="font-weight:700">Email Rules</div>
<div style="display:flex;gap:10px">
<a href="/admin/email">Email Accounts</a>
<a href="/">Dashboard</a>
<a href="/logout">Logout</a>
</div>
</div>
</header>
<main>
<div class="card">
<h2 style="margin:0 0 8px">Create rule</h2>
<form method="post" action="/admin/email-rules/create" class="grid">
<div class="col4">
<label class="muted">Project</label>
<select name="projectId" required>
<% projects.forEach(p => { %>
<option value="<%= p.id %>"><%= p.name %> (<%= p.job_number || '—' %>)</option>
<% }) %>
</select>
</div>
<div class="col4">
<label class="muted">Match type</label>
<select name="matchType">
<option value="from_domain">From domain</option>
<option value="from_contains">From contains</option>
<option value="subject_contains">Subject contains</option>
<option value="body_contains">Body contains</option>
<option value="thread_key">Thread key</option>
</select>
</div>
<div class="col4">
<label class="muted">Match value</label>
<input name="matchValue" placeholder="@gc.com or 0222600001 or project name" required />
</div>
<div class="col4">
<label class="muted">Priority (lower = earlier)</label>
<input name="priority" value="100" />
</div>
<div style="grid-column:span 12">
<button type="submit">Add rule</button>
</div>
</form>
</div>
<div class="card">
<h2 style="margin:0 0 8px">Rules</h2>
<div style="overflow:auto">
<table>
<thead>
<tr>
<th>Enabled</th>
<th>Priority</th>
<th>Project</th>
<th>Type</th>
<th>Value</th>
<th></th>
</tr>
</thead>
<tbody>
<% rules.forEach(r => { %>
<tr>
<td><%= r.enabled ? 'yes' : 'no' %></td>
<td><%= r.priority %></td>
<td><%= r.project_name %></td>
<td><%= r.match_type %></td>
<td><%= r.match_value %></td>
<td>
<form method="post" action="/admin/email-rules/<%= r.id %>/toggle" style="display:inline">
<button type="submit"><%= r.enabled ? 'Disable' : 'Enable' %></button>
</form>
<form method="post" action="/admin/email-rules/<%= r.id %>/delete" onsubmit="return confirm('Delete rule?');" style="display:inline">
<button type="submit" style="background:#2b1216;border-color:#5a1e28">Delete</button>
</form>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
</main>
</body>
</html>

View File

@@ -0,0 +1,86 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Mastermind — Email Accounts</title>
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:0;background:#0b0f14;color:#e8eef7}
header{background:#111824;border-bottom:1px solid #233043;padding:14px 16px;position:sticky;top:0}
main{max-width:1100px;margin:0 auto;padding:16px}
.card{background:#111824;border:1px solid #233043;border-radius:12px;padding:14px;margin:12px 0}
a{color:#7cc4ff;text-decoration:none}
.muted{color:#a9b7c6}
table{width:100%;border-collapse:collapse}
th,td{border-bottom:1px solid #233043;padding:10px 6px;text-align:left;color:#a9b7c6;font-size:13px;vertical-align:top}
th{color:#e8eef7}
button{padding:10px 12px;border-radius:10px;border:1px solid #233043;background:#1c2a3d;color:#e8eef7}
.pill{display:inline-block;padding:2px 8px;border:1px solid #233043;border-radius:999px;font-size:12px;color:#a9b7c6}
</style>
</head>
<body>
<header>
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px">
<div style="font-weight:700">Email Accounts</div>
<div style="display:flex;gap:10px">
<a href="/admin/email-rules">Rules</a>
<a href="/">Dashboard</a>
<a href="/logout">Logout</a>
</div>
</div>
</header>
<main>
<div class="card">
<p class="muted">Connectors are first-class from day 1. Theyll show <b>Not configured</b> until you provide OAuth credentials. Manual .eml upload works now.</p>
</div>
<div class="card">
<h2 style="margin:0 0 8px">Connectors</h2>
<table>
<thead>
<tr>
<th>Provider</th>
<th>Enabled</th>
<th>Configured</th>
<th>Authorized</th>
<th>Last sync</th>
<th>Last error</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<% connectors.forEach(c => { %>
<tr>
<td><b><%= c.provider %></b></td>
<td><span class="pill"><%= c.enabled ? 'yes' : 'no' %></span></td>
<td><span class="pill"><%= c.configured ? 'yes' : 'no' %></span></td>
<td><span class="pill"><%= c.authorized ? 'yes' : 'no' %></span></td>
<td><%= c.last_sync_at || '' %></td>
<td><%= c.last_error || '' %></td>
<td>
<form method="post" action="/admin/email-connectors/<%= c.provider %>/toggle">
<button type="submit"><%= c.enabled ? 'Disable' : 'Enable' %></button>
</form>
<div class="muted" style="margin-top:6px">
<% if (!c.configured) { %>
Not configured — add OAuth credentials in <code>.env</code> (well add UI later).
<% } else if (!c.authorized) { %>
Configured — needs OAuth authorization (pending).
<% } else { %>
Connected.
<% } %>
</div>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<div class="card">
<h2 style="margin:0 0 8px">Manual import</h2>
<p><a href="/inbox">→ Inbox import + triage</a></p>
</div>
</main>
</body>
</html>

73
web/src/views/home.ejs Normal file
View File

@@ -0,0 +1,73 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Mastermind — Dashboard</title>
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:0;background:#0b0f14;color:#e8eef7}
header{position:sticky;top:0;background:#111824;border-bottom:1px solid #233043;padding:14px 16px}
main{max-width:1100px;margin:0 auto;padding:16px}
.card{background:#111824;border:1px solid #233043;border-radius:12px;padding:14px;margin:12px 0}
a{color:#7cc4ff;text-decoration:none}
a:hover{text-decoration:underline}
.muted{color:#a9b7c6}
.grid{display:grid;grid-template-columns:repeat(12,1fr);gap:12px}
.col6{grid-column:span 12}
@media(min-width:900px){.col6{grid-column:span 6}}
</style>
</head>
<body>
<header>
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px">
<div>
<div style="font-weight:700">Mastermind</div>
<div class="muted" style="font-size:13px">Logged in as <%= user.email || user.display_name || user.id %></div>
</div>
<div style="display:flex;gap:10px">
<a href="/account/password">Password</a>
<a href="/setup">Setup</a>
<a href="/docs">Docs</a>
<% if (user.role === 'owner') { %>
<a href="/admin/email">Email</a>
<a href="/admin/email-rules">Rules</a>
<a href="/admin/users">Users</a>
<a href="/admin/audit">Audit</a>
<% } %>
<a href="/logout">Logout</a>
</div>
</div>
</header>
<main>
<div class="grid">
<div class="card col6">
<h2 style="margin:0 0 8px">MVP status</h2>
<div class="muted">Auth is wired (local + optional Google/Microsoft). Next: project wizard + inbox ingestion.</div>
</div>
<div class="card col6">
<h2 style="margin:0 0 8px">Base URL</h2>
<div class="muted">Current: <code><%= baseUrl %></code></div>
<div class="muted" style="margin-top:6px">If OAuth callbacks dont work, update Base URL under Setup.</div>
</div>
</div>
<div class="card">
<h2 style="margin:0 0 8px">Start</h2>
<p class="muted">Create a project profile so Mastermind can sort your single mailbox automatically.</p>
<p><a href="/projects">→ Projects</a></p>
<p><a href="/inbox">→ Inbox (import + triage)</a></p>
<p><a href="/drafts">→ Drafts (PCO / RFI)</a></p>
</div>
<div class="card">
<h2 style="margin:0 0 8px">Next build steps</h2>
<ol class="muted">
<li>Inbox triage queue (single mailbox → auto-sort per project)</li>
<li>RFI/Submittal/PCO logs + draft generators with citations</li>
<li>Email connectors (Gmail + Microsoft) behind OAuth + local fallback imports</li>
</ol>
</div>
</main>
</body>
</html>

114
web/src/views/inbox.ejs Normal file
View File

@@ -0,0 +1,114 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Mastermind — Inbox</title>
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:0;background:#0b0f14;color:#e8eef7}
header{background:#111824;border-bottom:1px solid #233043;padding:14px 16px;position:sticky;top:0}
main{max-width:1200px;margin:0 auto;padding:16px}
.card{background:#111824;border:1px solid #233043;border-radius:12px;padding:14px;margin:12px 0}
a{color:#7cc4ff;text-decoration:none}
.muted{color:#a9b7c6}
input,select{padding:12px;border-radius:10px;border:1px solid #233043;background:#0e1520;color:#e8eef7;width:100%}
button{padding:12px 14px;border-radius:10px;border:1px solid #233043;background:#1c2a3d;color:#e8eef7}
table{width:100%;border-collapse:collapse}
th,td{border-bottom:1px solid #233043;padding:10px 6px;text-align:left;color:#a9b7c6;font-size:13px;vertical-align:top}
th{color:#e8eef7}
.grid{display:grid;grid-template-columns:repeat(12,1fr);gap:12px}
.col6{grid-column:span 12}
@media(min-width:900px){.col6{grid-column:span 6}}
.actions{display:flex;gap:8px;flex-wrap:wrap}
</style>
</head>
<body>
<header>
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px">
<div style="font-weight:700">Inbox</div>
<div style="display:flex;gap:10px">
<a href="/projects">Projects</a>
<a href="/">Dashboard</a>
<a href="/logout">Logout</a>
</div>
</div>
</header>
<main>
<div class="card">
<h2 style="margin:0 0 8px">Import emails (until OAuth is connected)</h2>
<div class="muted">Upload .eml files. Mastermind stores them and puts them into the Unsorted queue for triage.</div>
<form method="post" action="/inbox/upload" enctype="multipart/form-data" class="grid" style="margin-top:10px">
<div class="col6">
<label class="muted">.eml files</label>
<input type="file" name="emls" multiple accept=".eml,message/rfc822" required />
</div>
<div class="col6">
<label class="muted">Source label</label>
<input name="source" placeholder="outlook-export" value="upload" />
</div>
<div style="grid-column:span 12">
<button type="submit">Import</button>
</div>
</form>
</div>
<div class="card">
<h2 style="margin:0 0 8px">Unsorted</h2>
<div class="muted">Assign each email to a project. Later this will be automated with rules + OAuth sync.</div>
<div style="overflow:auto;margin-top:10px">
<table>
<thead>
<tr>
<th>Date</th>
<th>From</th>
<th>Subject</th>
<th>Assign</th>
</tr>
</thead>
<tbody>
<% emails.forEach(e => { %>
<tr>
<td><%= e.date || '' %></td>
<td><%= e.from_addr || '' %></td>
<td><a href="/inbox/<%= e.id %>"><%= e.subject || '(no subject)' %></a></td>
<td>
<form method="post" action="/inbox/<%= e.id %>/assign" class="actions" style="margin-bottom:8px">
<select name="projectId" required>
<option value="" disabled selected>Select project…</option>
<% projects.forEach(p => { %>
<option value="<%= p.id %>"><%= p.name %> (<%= p.job_number || '—' %>)</option>
<% }) %>
</select>
<button type="submit">Assign</button>
</form>
<div class="muted" style="font-size:12px;margin-bottom:6px">Make a rule from this email:</div>
<form method="post" action="/inbox/<%= e.id %>/rule/from_domain" class="actions" style="margin-bottom:8px">
<select name="projectId" required>
<option value="" disabled selected>Project…</option>
<% projects.forEach(p => { %>
<option value="<%= p.id %>"><%= p.name %></option>
<% }) %>
</select>
<button type="submit">Rule: From domain</button>
</form>
<form method="post" action="/inbox/<%= e.id %>/rule/subject_job" class="actions">
<select name="projectId" required>
<option value="" disabled selected>Project…</option>
<% projects.forEach(p => { %>
<option value="<%= p.id %>"><%= p.name %></option>
<% }) %>
</select>
<button type="submit">Rule: Subject job#</button>
</form>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
</main>
</body>
</html>

View File

@@ -0,0 +1,88 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Mastermind — Email</title>
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:0;background:#0b0f14;color:#e8eef7}
header{background:#111824;border-bottom:1px solid #233043;padding:14px 16px;position:sticky;top:0}
main{max-width:1100px;margin:0 auto;padding:16px}
.card{background:#111824;border:1px solid #233043;border-radius:12px;padding:14px;margin:12px 0}
a{color:#7cc4ff;text-decoration:none}
.muted{color:#a9b7c6}
pre{white-space:pre-wrap;background:#0e1520;border:1px solid #233043;border-radius:10px;padding:12px;color:#d6e3f3}
table{width:100%;border-collapse:collapse}
th,td{border-bottom:1px solid #233043;padding:10px 6px;text-align:left;color:#a9b7c6;font-size:13px;vertical-align:top}
th{color:#e8eef7}
button{padding:10px 12px;border-radius:10px;border:1px solid #233043;background:#1c2a3d;color:#e8eef7}
</style>
</head>
<body>
<header>
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px">
<div style="font-weight:700">Email</div>
<div style="display:flex;gap:10px">
<a href="/inbox">Inbox</a>
<a href="/projects">Projects</a>
<a href="/">Dashboard</a>
</div>
</div>
</header>
<main>
<div class="card">
<div class="muted"><b>Date:</b> <%= email.date || '' %></div>
<div class="muted"><b>From:</b> <%= email.from_addr || '' %></div>
<div class="muted"><b>To:</b> <%= email.to_addr || '' %></div>
<div class="muted"><b>Subject:</b> <%= email.subject || '' %></div>
<div class="muted"><b>Status:</b> <%= email.status %></div>
<div class="muted"><b>Raw file:</b> <%= email.raw_path || '' %></div>
<div style="height:10px"></div>
<div style="display:flex;gap:10px;flex-wrap:wrap">
<form method="post" action="/drafts/pco/from-email">
<input type="hidden" name="emailId" value="<%= email.id %>" />
<button type="submit">Create PCO draft from this email</button>
</form>
<form method="post" action="/drafts/rfi/from-email">
<input type="hidden" name="emailId" value="<%= email.id %>" />
<button type="submit">Create RFI draft from this email</button>
</form>
</div>
</div>
<div class="card">
<h2 style="margin:0 0 8px">Attachments</h2>
<% if (!attachments.length) { %>
<div class="muted">No attachments stored for this email.</div>
<% } else { %>
<table>
<thead>
<tr>
<th>Filename</th>
<th>Type</th>
<th>Size</th>
<th></th>
</tr>
</thead>
<tbody>
<% attachments.forEach(a => { %>
<tr>
<td><%= a.filename || '' %></td>
<td><%= a.content_type || '' %></td>
<td><%= a.size_bytes || '' %></td>
<td><a href="/attachments/<%= a.id %>">Download</a></td>
</tr>
<% }) %>
</tbody>
</table>
<% } %>
</div>
<div class="card">
<h2 style="margin:0 0 8px">Body (text)</h2>
<pre><%= email.body_text || '' %></pre>
</div>
</main>
</body>
</html>

49
web/src/views/login.ejs Normal file
View File

@@ -0,0 +1,49 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Mastermind — Login</title>
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:0;background:#0b0f14;color:#e8eef7}
.wrap{max-width:420px;margin:0 auto;padding:28px}
.card{background:#111824;border:1px solid #233043;border-radius:12px;padding:16px}
label{display:block;margin:10px 0 6px;color:#a9b7c6}
input{width:100%;padding:12px;border-radius:10px;border:1px solid #233043;background:#0e1520;color:#e8eef7}
button{width:100%;padding:12px;border-radius:10px;border:1px solid #233043;background:#1c2a3d;color:#e8eef7;margin-top:12px}
.row{display:flex;gap:10px;margin-top:12px}
.row a{flex:1;text-align:center;text-decoration:none;padding:12px;border-radius:10px;border:1px solid #233043;color:#7cc4ff;background:#0e1520}
.muted{color:#a9b7c6;font-size:13px;line-height:1.4}
</style>
</head>
<body>
<div class="wrap">
<h1 style="margin:0 0 8px">Mastermind</h1>
<div class="muted">Local login + Google/Microsoft OAuth. Mobile friendly.</div>
<div style="height:12px"></div>
<div class="card">
<form method="post" action="/login">
<label>Email</label>
<input name="email" placeholder="you@company.com" />
<label>Password</label>
<input name="password" type="password" placeholder="••••••••" />
<button type="submit">Sign in (local)</button>
</form>
<div class="row">
<% if (googleEnabled) { %>
<a href="/auth/google">Google</a>
<% } %>
<% if (microsoftEnabled) { %>
<a href="/auth/microsoft">Microsoft</a>
<% } %>
</div>
<p class="muted" style="margin-top:12px">
First run creates <b>owner@local / owner</b> (change ASAP).
</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,80 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Mastermind — Project</title>
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:0;background:#0b0f14;color:#e8eef7}
header{background:#111824;border-bottom:1px solid #233043;padding:14px 16px;position:sticky;top:0}
main{max-width:1100px;margin:0 auto;padding:16px}
.card{background:#111824;border:1px solid #233043;border-radius:12px;padding:14px;margin:12px 0}
a{color:#7cc4ff;text-decoration:none}
.muted{color:#a9b7c6}
.grid{display:grid;grid-template-columns:repeat(12,1fr);gap:12px}
.col6{grid-column:span 12}
@media(min-width:900px){.col6{grid-column:span 6}}
input,select{padding:12px;border-radius:10px;border:1px solid #233043;background:#0e1520;color:#e8eef7;width:100%}
button{padding:12px 14px;border-radius:10px;border:1px solid #233043;background:#1c2a3d;color:#e8eef7}
.pill{display:inline-block;padding:2px 8px;border:1px solid #233043;border-radius:999px;font-size:12px;color:#a9b7c6}
</style>
</head>
<body>
<header>
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px">
<div>
<div style="font-weight:700"><%= project.name %></div>
<div class="muted" style="font-size:13px">Job: <%= project.job_number || '—' %> · Mode: <span class="pill"><%= project.role_mode %></span></div>
</div>
<div style="display:flex;gap:10px">
<a href="/projects/<%= project.id %>/inbox">Inbox</a>
<a href="/projects">Projects</a>
<a href="/">Dashboard</a>
</div>
</div>
</header>
<main>
<div class="card">
<h2 style="margin:0 0 8px">Project profile</h2>
<form method="post" action="/projects/<%= project.id %>/update" class="grid">
<div class="col6">
<label class="muted">Name</label>
<input name="name" value="<%= project.name %>" required />
</div>
<div class="col6">
<label class="muted">Job #</label>
<input name="jobNumber" value="<%= project.job_number || '' %>" />
</div>
<div class="col6">
<label class="muted">Mode</label>
<select name="roleMode">
<option value="ec" <%= project.role_mode==='ec'?'selected':'' %>>Electrical contractor</option>
<option value="gc" <%= project.role_mode==='gc'?'selected':'' %>>General contractor</option>
</select>
</div>
<div class="col6">
<label class="muted">GC name</label>
<input name="gcName" value="<%= project.gc_name || '' %>" />
</div>
<div style="grid-column:span 12">
<label class="muted">Keywords (used for email sorting)</label>
<input name="keywords" value="<%= project.keywords || '' %>" />
</div>
<div style="grid-column:span 12">
<button type="submit">Save</button>
</div>
</form>
</div>
<div class="card">
<h2 style="margin:0 0 8px">Next modules</h2>
<ul class="muted">
<li>Inbox for this project (auto-sorted from the single mailbox)</li>
<li>Logs: RFI / Submittals / PCO / Procurement</li>
<li>Draft tools: create RFI or PCO from an email</li>
</ul>
</div>
</main>
</body>
</html>

View File

@@ -0,0 +1,63 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Mastermind — Project Inbox</title>
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:0;background:#0b0f14;color:#e8eef7}
header{background:#111824;border-bottom:1px solid #233043;padding:14px 16px;position:sticky;top:0}
main{max-width:1200px;margin:0 auto;padding:16px}
.card{background:#111824;border:1px solid #233043;border-radius:12px;padding:14px;margin:12px 0}
a{color:#7cc4ff;text-decoration:none}
.muted{color:#a9b7c6}
table{width:100%;border-collapse:collapse}
th,td{border-bottom:1px solid #233043;padding:10px 6px;text-align:left;color:#a9b7c6;font-size:13px;vertical-align:top}
th{color:#e8eef7}
</style>
</head>
<body>
<header>
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px">
<div>
<div style="font-weight:700"><%= project.name %> — Inbox</div>
<div class="muted" style="font-size:13px">Job: <%= project.job_number || '—' %></div>
</div>
<div style="display:flex;gap:10px">
<a href="/projects/<%= project.id %>">Project</a>
<a href="/projects">Projects</a>
<a href="/inbox">Global Inbox</a>
<a href="/">Dashboard</a>
</div>
</div>
</header>
<main>
<div class="card">
<h2 style="margin:0 0 8px">Assigned emails</h2>
<div style="overflow:auto">
<table>
<thead>
<tr>
<th>Date</th>
<th>From</th>
<th>Subject</th>
<th>Source</th>
</tr>
</thead>
<tbody>
<% emails.forEach(e => { %>
<tr>
<td><%= e.date || '' %></td>
<td><%= e.from_addr || '' %></td>
<td><a href="/inbox/<%= e.id %>"><%= e.subject || '(no subject)' %></a></td>
<td><%= e.source %></td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
</main>
</body>
</html>

113
web/src/views/projects.ejs Normal file
View File

@@ -0,0 +1,113 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Mastermind — Projects</title>
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:0;background:#0b0f14;color:#e8eef7}
header{background:#111824;border-bottom:1px solid #233043;padding:14px 16px;position:sticky;top:0}
main{max-width:1100px;margin:0 auto;padding:16px}
.card{background:#111824;border:1px solid #233043;border-radius:12px;padding:14px;margin:12px 0}
a{color:#7cc4ff;text-decoration:none}
.muted{color:#a9b7c6}
.grid{display:grid;grid-template-columns:repeat(12,1fr);gap:12px}
.col6{grid-column:span 12}
@media(min-width:900px){.col6{grid-column:span 6}}
input,select{padding:12px;border-radius:10px;border:1px solid #233043;background:#0e1520;color:#e8eef7;width:100%}
button{padding:12px 14px;border-radius:10px;border:1px solid #233043;background:#1c2a3d;color:#e8eef7}
table{width:100%;border-collapse:collapse}
th,td{border-bottom:1px solid #233043;padding:10px 6px;text-align:left;color:#a9b7c6;font-size:13px;vertical-align:top}
th{color:#e8eef7}
.pill{display:inline-block;padding:2px 8px;border:1px solid #233043;border-radius:999px;font-size:12px;color:#a9b7c6}
</style>
</head>
<body>
<header>
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px">
<div style="font-weight:700">Projects</div>
<div style="display:flex;gap:10px">
<a href="/">Dashboard</a>
<a href="/logout">Logout</a>
</div>
</div>
</header>
<main>
<div class="card">
<h2 style="margin:0 0 8px">New project (2-minute setup)</h2>
<div class="muted">Minimum fields: name + job #. Add keywords if sorting needs help.</div>
<form method="post" action="/projects/create" class="grid" style="margin-top:10px">
<div class="col6">
<label class="muted">Project name</label>
<input name="name" placeholder="1100 Columbia FA" required />
</div>
<div class="col6">
<label class="muted">Job #</label>
<input name="jobNumber" placeholder="0222600001" />
</div>
<div class="col6">
<label class="muted">Mode</label>
<select name="roleMode">
<option value="ec">Electrical contractor</option>
<option value="gc">General contractor</option>
</select>
</div>
<div class="col6">
<label class="muted">GC name</label>
<input name="gcName" placeholder="GC / Construction Manager" />
</div>
<div class="col6">
<label class="muted">City</label>
<input name="city" placeholder="Chapel Hill" />
</div>
<div class="col6">
<label class="muted">State</label>
<input name="state" placeholder="NC" />
</div>
<div style="grid-column:span 12">
<label class="muted">Keywords (optional)</label>
<input name="keywords" placeholder="owner name, building nickname, address fragments, common subject tags" />
</div>
<div style="grid-column:span 12">
<button type="submit">Create project</button>
</div>
</form>
</div>
<div class="card">
<h2 style="margin:0 0 8px">Your projects</h2>
<div style="overflow:auto">
<table>
<thead>
<tr>
<th>Name</th>
<th>Job #</th>
<th>Mode</th>
<th>GC</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
<% projects.forEach(p => { %>
<tr>
<td><a href="/projects/<%= p.id %>"><%= p.name %></a></td>
<td><%= p.job_number || '' %></td>
<td><span class="pill"><%= p.role_mode %></span></td>
<td><%= p.gc_name || '' %></td>
<td><%= p.active ? 'active' : 'archived' %></td>
<td>
<form method="post" action="/projects/<%= p.id %>/toggle" style="display:inline">
<button type="submit"><%= p.active ? 'Archive' : 'Unarchive' %></button>
</form>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
</main>
</body>
</html>

37
web/src/views/setup.ejs Normal file
View File

@@ -0,0 +1,37 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Mastermind — Setup</title>
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:0;background:#0b0f14;color:#e8eef7}
header{background:#111824;border-bottom:1px solid #233043;padding:14px 16px}
main{max-width:700px;margin:0 auto;padding:16px}
.card{background:#111824;border:1px solid #233043;border-radius:12px;padding:14px;margin:12px 0}
label{display:block;margin:10px 0 6px;color:#a9b7c6}
input{width:100%;padding:12px;border-radius:10px;border:1px solid #233043;background:#0e1520;color:#e8eef7}
button{padding:12px 14px;border-radius:10px;border:1px solid #233043;background:#1c2a3d;color:#e8eef7;margin-top:12px}
a{color:#7cc4ff;text-decoration:none}
</style>
</head>
<body>
<header>
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px">
<div style="font-weight:700">Setup</div>
<a href="/">Back</a>
</div>
</header>
<main>
<div class="card">
<h2 style="margin:0 0 8px">Public Base URL</h2>
<div style="color:#a9b7c6;font-size:13px;line-height:1.4">This should be the URL you use to reach the app (e.g., Tailscale IP). OAuth callbacks rely on this.</div>
<form method="post" action="/setup/base-url">
<label>Base URL</label>
<input name="baseUrl" value="<%= baseUrl %>" />
<button type="submit">Save</button>
</form>
</div>
</main>
</body>
</html>

6
worker/Dockerfile Normal file
View File

@@ -0,0 +1,6 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["npm","run","dev"]

173
worker/package-lock.json generated Normal file
View File

@@ -0,0 +1,173 @@
{
"name": "mastermind-worker",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mastermind-worker",
"dependencies": {
"dotenv": "^16.4.5",
"pg": "^8.12.0"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/pg": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz",
"integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.11.0",
"pg-pool": "^3.11.0",
"pg-protocol": "^1.11.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.3.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz",
"integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz",
"integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz",
"integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
}
}
}

13
worker/package.json Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "mastermind-worker",
"private": true,
"type": "module",
"scripts": {
"dev": "node --watch src/worker.js",
"start": "node src/worker.js"
},
"dependencies": {
"dotenv": "^16.4.5",
"pg": "^8.12.0"
}
}

20
worker/src/worker.js Normal file
View File

@@ -0,0 +1,20 @@
import 'dotenv/config';
import pg from 'pg';
const { Pool } = pg;
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// Placeholder worker loop
async function main() {
// eslint-disable-next-line no-console
console.log('Mastermind worker started');
while (true) {
await new Promise((r) => setTimeout(r, 10_000));
// future: ingest inbox, OCR docs, classify
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});