screentinker/server/middleware/auth.js
ScreenTinker c71c4016ca feat(email): Microsoft Graph send + alert spam protection + preferences UI
Replaces the unused EMAIL_WEBHOOK_URL stub with a real Microsoft Graph
Mail.Send pipeline via @azure/msal-node client-credentials flow. Prior
state on prod: every alert email was logged to journalctl and never
sent (21 fallback log lines per hour for the chronic-offline devices).

Four coordinated changes shipped as one commit since they're all part
of making email delivery actually work responsibly:

1. services/email.js (NEW): Graph send via plain HTTPS (no SDK), in-memory
   MSAL token cache (refresh 60s pre-expiry), graceful stdout fallback
   when GRAPH_* env vars absent. Drop-in replacement for the old webhook.

2. services/alerts.js refactored: sequential await around sendEmail (was
   parallel fire-and-forget; first run hit Graph's MailboxConcurrency 429
   ApplicationThrottled on a 30-device backlog). Sequential at ~250ms per
   send takes 5-8s for the full backlog, well within the 60s tick. Also:
   24h long-offline cutoff to stop nagging about chronic-offline devices
   (the 20,000+ minute ones); 2-hour dedup window (was 1h) via a generic
   shouldSendAlert(type, id, windowMs) helper that future alert types
   (payment_failed, plan_limit_hit, etc.) can reuse.

3. Preferences UI: single checkbox in settings.js Account section bound
   to users.email_alerts. Saved via the existing Save Profile button. PUT
   /api/auth/me extended to accept email_alerts. requireAuth middleware
   SELECT now includes email_alerts so it propagates via req.user.

4. Dev safety net: GRAPH_DEV_RESTRICT_TO env var as an allow-list. When
   set, only listed recipients reach Graph; everyone else is suppressed
   with a log line. Prevents local dev (which often runs against fresh
   prod DB copies) from accidentally emailing real prod users. UNSET on
   prod systemd unit so production fans out normally.

Also: package.json scripts use --env-file-if-exists=.env so local dev
picks up .env automatically (Node 20.6+ built-in, no dotenv dep). Prod
runs via systemd ExecStart and is unaffected. server/.gitignore added
to keep .env out of git.

Smoke verified end-to-end:
- Sequential send pattern verified (a prior parallel-send tick had hit
  Graph's MailboxConcurrency 429 on 30 simultaneous sends; sequential
  at ~250ms each completes the same backlog without throttling)
- 24h cutoff silenced 20/21 prod devices on the next tick
- Dev restrict suppressed the 1 within-24h send
- User-preference toggle flipped via UI -> DB -> alert path silently
  continued before reaching even the suppression log
2026-05-12 18:16:40 -05:00

107 lines
3.9 KiB
JavaScript

const jwt = require('jsonwebtoken');
const config = require('../config');
const { db } = require('../db/database');
// Phase 2.1: JWT now optionally carries the user's current workspace_id so
// the tenancy middleware can resolve scope without an extra DB lookup on
// every request. Callers that don't know the workspace yet (legacy paths,
// recovery tokens) pass null and the tenancy resolver falls back to the
// user's first accessible workspace.
function generateToken(user, currentWorkspaceId) {
return jwt.sign(
{ id: user.id, email: user.email, role: user.role, current_workspace_id: currentWorkspaceId || null },
config.jwtSecret,
{ algorithm: 'HS256', expiresIn: config.jwtExpiry }
);
}
function verifyToken(token) {
return jwt.verify(token, config.jwtSecret, { algorithms: ['HS256'] });
}
// Synthetic user record for recovery tokens (scripts/reset-admin.js). Not
// persisted; only exists for the lifetime of the request.
function recoveryUser(decoded) {
return {
id: decoded.id,
email: decoded.email || 'admin@localhost',
name: 'Recovery Admin',
role: decoded.role || 'admin',
auth_provider: 'recovery',
avatar_url: null,
plan_id: 'enterprise'
};
}
// Express middleware - requires valid JWT
function requireAuth(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authentication required' });
}
try {
const token = authHeader.split(' ')[1];
const decoded = verifyToken(token);
if (decoded.recovery) {
req.user = recoveryUser(decoded);
req.jwtWorkspaceId = null;
return next();
}
const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id, email_alerts FROM users WHERE id = ?').get(decoded.id);
if (!user) return res.status(401).json({ error: 'User not found' });
req.user = user;
// Tenancy middleware reads this on the resolver step.
req.jwtWorkspaceId = decoded.current_workspace_id || null;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
// Optional auth - sets req.user if token present, continues either way
function optionalAuth(req, res, next) {
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
try {
const token = authHeader.split(' ')[1];
const decoded = verifyToken(token);
req.user = decoded.recovery
? recoveryUser(decoded)
: db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id FROM users WHERE id = ?').get(decoded.id);
req.jwtWorkspaceId = decoded.current_workspace_id || null;
} catch (err) {
// Token invalid, continue without user
}
}
next();
}
// Phase 2.1: role rename. Phase 1 renamed 'superadmin' to 'platform_admin' and
// dropped the in-between 'admin' role. These two guards are widened to accept
// either spelling so existing callers keep working without per-route edits.
// New code should prefer requirePlatformAdmin / requireOrgAdmin / workspace
// role guards from server/lib/permissions.js.
const PLATFORM_ROLES = ['superadmin', 'platform_admin'];
const ELEVATED_ROLES = ['admin', 'superadmin', 'platform_admin'];
function requireAdmin(req, res, next) {
if (!req.user || !ELEVATED_ROLES.includes(req.user.role)) {
return res.status(403).json({ error: 'Admin access required' });
}
next();
}
function requireSuperAdmin(req, res, next) {
if (!req.user || !PLATFORM_ROLES.includes(req.user.role)) {
return res.status(403).json({ error: 'Platform admin access required' });
}
next();
}
// Preferred alias for new code.
const requirePlatformAdmin = requireSuperAdmin;
module.exports = { generateToken, verifyToken, requireAuth, optionalAuth, requireAdmin, requireSuperAdmin, requirePlatformAdmin, PLATFORM_ROLES, ELEVATED_ROLES };