/** * Admin + me routes — DC-048. * * Mounted at /api/v1/auth. All `/admin/*` routes require the session to * belong to a user with role 'admin'. `/me` requires any authenticated session. * * Endpoints: * GET /me — current user (id, email, role, isAdmin) * GET /admin/users — list all users * POST /admin/users — pre-authorize an email (allowlist) * PATCH /admin/users/:id — change a user's role * DELETE /admin/users/:id — delete user + remove from allowlist * GET /admin/allowlist — list authorized emails * GET /admin/invites — list outstanding invites * POST /admin/invites — issue a new invite (returns raw token ONCE) * DELETE /admin/invites/:id — revoke an invite * * POST /invites/accept — PUBLIC — redeem an invite token, * create user, set session cookie * GET /invites/:token — PUBLIC — peek at an invite (email, * role, expires) without consuming it. */ 'use strict'; const express = require('express'); const path = require('path'); const platformPaths = require('../../platform-paths'); const { createUserStore } = require('../../src/security/user-store'); const { createInviteStore } = require('../../src/security/invite-store'); const emailSender = require('../../src/auth/providers/email-sender'); const { ValidationError, NotFoundError, ForbiddenError, PaymentRequiredError } = require('../../src/utilities/errors'); const { ok, successMessage } = require('../../src/utils/responses'); function _requireAdmin(req, _res, next) { if (!req.user || req.user.role !== 'admin') { return next(new ForbiddenError('Admin role required')); } next(); } /** * DC-052: license-tier gate for user-creation endpoints. * * Free = up to 3 users total. Pro = unlimited. When the count would * exceed the cap and the host isn't Pro, throw a PaymentRequiredError * so the caller knows exactly what to do. The error message names the * tier name ("Pro") so the upsell is clear. * * NOTE: passes through when the userStore isn't mounted (single-user * installs without email auth — those don't even have /admin/*). */ async function _requireProIfUserLimitReached(req, _res, next) { try { const licenseManager = req.app.locals && req.app.locals.licenseManager; if (!licenseManager || typeof licenseManager.isPro !== 'function') return next(); if (licenseManager.isPro()) return next(); const userStore = req.app.locals && req.app.locals.userStore; if (!userStore || typeof userStore.countUsers !== 'function') return next(); const count = await userStore.countUsers(); if (count >= 3) { return next(new PaymentRequiredError( 'Free tier supports up to 3 users. Upgrade to Pro for unlimited users.' )); } next(); } catch (e) { next(e); } } function _buildEmailText({ acceptUrl, ttlHours, role }) { return [ 'Hi,', '', 'You\'ve been invited to join a DashCaddy instance as a ' + role + '.', 'Click the link below within ' + ttlHours + ' hours to accept:', '', acceptUrl, '', 'This link is single-use. If you weren\'t expecting this invitation,', 'you can safely ignore this email.', '', '— DashCaddy', ].join('\n'); } function _buildEmailHtml({ acceptUrl, ttlHours, role }) { return [ '
', 'You\'ve been invited to join as ' + role + '.
', 'Click the button below within ' + ttlHours + ' hours to accept:
', '', 'If the button doesn\'t work, paste this link into your browser:
' + acceptUrl + '
If you weren\'t expecting this, you can ignore this email.
', '', ].join('\n'); } module.exports = function({ asyncHandler, errorResponse, log, session, dataDir }) { const router = express.Router(); // user-store / invite-store handle their own defensive dataDir resolution // (they ignore Proxy/function values from universal-deps test deps). const resolvedDataDir = dataDir || (platformPaths && platformPaths.dataDir); const userStore = createUserStore({ dataDir: resolvedDataDir, log }); const inviteStore = createInviteStore({ dataDir: resolvedDataDir, log }); // ── /me ─────────────────────────────────────────────────────────────── router.get('/me', asyncHandler(async (req, res) => { if (!req.user || !req.user.id) { // Legacy session without user attribution. Return the bare role // (defaults to admin for backwards-compat) but signal via // `legacy: true` so the UI knows. return ok(res, { user: null, authenticated: session ? session.isSessionValid(req) : false, role: 'admin', // legacy: assume operator-level access legacy: true, }); } const stored = await userStore.getUser(req.user.id); return ok(res, { user: stored ? { id: stored.id, email: stored.email, displayName: stored.displayName, role: stored.role, isAdmin: stored.role === 'admin', createdAt: stored.createdAt, lastLoginAt: stored.lastLoginAt, loginCount: stored.loginCount, } : null, authenticated: true, role: req.user.role, legacy: false, }); }, 'auth-me')); // ── /admin/users ────────────────────────────────────────────────────── router.get('/admin/users', _requireAdmin, asyncHandler(async (_req, res) => { const users = await userStore.listUsers(); return ok(res, { users }); }, 'auth-admin-users-list')); router.post('/admin/users', _requireAdmin, _requireProIfUserLimitReached, asyncHandler(async (req, res) => { const { email, role } = req.body || {}; if (!email) throw new ValidationError('email is required', 'email'); if (role && !userStore.VALID_ROLES.has(role)) { throw new ValidationError('Invalid role', 'role'); } const result = await userStore.addToAllowlist(email); if (!result.ok) throw new ValidationError(result.reason, 'email'); // If a role was provided AND the user already exists, also set the role. if (role) { const existing = await userStore.getUserByEmail(email); if (existing) { await userStore.setRole(existing.id, role); } } return ok(res, { email: email.toLowerCase(), alreadyExisted: result.alreadyExisted, }); }, 'auth-admin-users-create')); router.patch('/admin/users/:id', _requireAdmin, asyncHandler(async (req, res) => { const { role } = req.body || {}; if (!role || !userStore.VALID_ROLES.has(role)) { throw new ValidationError('Invalid role', 'role'); } const result = await userStore.setRole(req.params.id, role); if (!result.ok) { throw result.reason === 'not_found' ? new NotFoundError('User not found') : new ValidationError(result.reason, 'role'); } return successMessage(res, 'Role updated'); }, 'auth-admin-users-update')); router.delete('/admin/users/:id', _requireAdmin, asyncHandler(async (req, res) => { const result = await userStore.deleteUser(req.params.id); if (!result.ok) { if (result.reason === 'not_found') throw new NotFoundError('User not found'); if (result.reason === 'last_admin') { throw new ValidationError('Cannot delete the last admin'); } throw new ValidationError(result.reason); } return successMessage(res, 'User deleted'); }, 'auth-admin-users-delete')); // ── /admin/allowlist ────────────────────────────────────────────────── router.get('/admin/allowlist', _requireAdmin, asyncHandler(async (_req, res) => { const emails = await userStore.listAllowlist(); return ok(res, { emails }); }, 'auth-admin-allowlist')); // ── /admin/invites ──────────────────────────────────────────────────── router.get('/admin/invites', _requireAdmin, asyncHandler(async (_req, res) => { const invites = await inviteStore.listOutstanding(); return ok(res, { invites }); }, 'auth-admin-invites-list')); router.post('/admin/invites', _requireAdmin, _requireProIfUserLimitReached, asyncHandler(async (req, res) => { const { email, role, ttlHours, sendEmail } = req.body || {}; if (!email) throw new ValidationError('email is required', 'email'); const ttlMs = (typeof ttlHours === 'number' && ttlHours > 0 && ttlHours <= 168) ? ttlHours * 60 * 60 * 1000 : inviteStore.DEFAULT_TTL_MS; const invitedBy = (req.user && req.user.email) || 'admin'; const issued = await inviteStore.issue({ email, role: (role && userStore.VALID_ROLES.has(role)) ? role : 'operator', ttlMs, invitedBy, }); if (!issued.ok) throw new ValidationError(issued.reason, 'email'); // Build the accept URL once — used both for the response and for email delivery. const baseUrl = (req.app.locals && req.app.locals.siteConfig && req.app.locals.siteConfig.publicBaseUrl ? req.app.locals.siteConfig.publicBaseUrl.replace(/\/+$/, '') : ((req.headers['x-forwarded-proto'] || req.protocol || 'https') + '://' + (req.headers['x-forwarded-host'] || req.headers.host || 'localhost:3001'))); const acceptUrl = baseUrl + '/api/v1/auth/invites/' + encodeURIComponent(issued.token) + '/accept'; // DC-085: link-first delivery. Default = no email, just hand the link back. // Operators opt INTO email by sending { sendEmail: true } (or the admin UI // checks the "Send email" checkbox). When SMTP is unconfigured AND the // operator did opt in, we surface the failure as `deliveredVia: 'failed'` // but NEVER leak the raw token into the server log — the link is already // in the response, so the operator has a UI-side fallback. let deliveredVia = 'manual'; if (sendEmail === true) { const ttlHoursOut = Math.round(issued.ttlMs / (60 * 60 * 1000)); const text = _buildEmailText({ acceptUrl, ttlHours: ttlHoursOut, role: issued.role }); const html = _buildEmailHtml({ acceptUrl, ttlHours: ttlHoursOut, role: issued.role }); try { const smtpConfig = req.app.locals && req.app.locals.emailConfig; if (smtpConfig && emailSender.isConfigured(smtpConfig)) { await emailSender.sendEmail(smtpConfig, issued.email, 'You\'re invited to DashCaddy', text, html); deliveredVia = 'email'; } else { // Operator asked for email but SMTP isn't configured. Surface the // failure cleanly; the link is still in the response so the // operator can share it manually. Do NOT log the raw URL — it // would duplicate what's already in the response and pollute the // server log on every unconfigured-install invite. log.warn && log.warn('auth-invite-send', 'invite send skipped: SMTP not configured (operator opted in)', { inviteId: issued.id, email: issued.email }); deliveredVia = 'failed'; } } catch (sendErr) { log.warn && log.warn('auth-invite-send', 'invite send failed: ' + (sendErr.message || String(sendErr)), { inviteId: issued.id }); deliveredVia = 'failed'; } } const maskedEmail = email.replace(/(^.).+(@.*$)/, '$1***$2'); const ttlHoursOut = Math.round(issued.ttlMs / (60 * 60 * 1000)); const shareText = 'Join my DashCaddy as ' + issued.role + ' — ' + acceptUrl + ' — expires in ' + ttlHoursOut + 'h.'; return ok(res, { id: issued.id, email: issued.email, role: issued.role, expiresAt: issued.expiresAt, acceptUrl, shareText, deliveredVia, maskedEmail, }); }, 'auth-admin-invites-create')); router.delete('/admin/invites/:id', _requireAdmin, asyncHandler(async (req, res) => { const result = await inviteStore.revoke(req.params.id); if (!result.ok) throw new NotFoundError('Invite not found'); return successMessage(res, 'Invite revoked'); }, 'auth-admin-invites-revoke')); // ── /invites (public) ────────────────────────────────────────────────── // PUBLIC: peek at an invite without consuming it. router.get('/invites/:token', asyncHandler(async (req, res) => { const peeked = await inviteStore.peek(req.params.token); if (!peeked) { // Same response as "not found" — don't leak token state. return ok(res, { valid: false }); } return ok(res, { valid: true, email: peeked.email, role: peeked.role, expiresAt: peeked.expiresAt, }); }, 'auth-invites-peek')); // PUBLIC: accept an invite token. Creates the user, sets the session. // DC-052: gated by Pro-or-room — if the user cap is hit and the host // isn't Pro, reject before the user is created. The invite token is // still marked used so a stale invite can't be replayed later when // room opens up. router.post('/invites/:token/accept', asyncHandler(async (req, res) => { const licenseManager = req.app.locals && req.app.locals.licenseManager; const localUserStore = req.app.locals && req.app.locals.userStore; if (licenseManager && typeof licenseManager.isPro === 'function' && !licenseManager.isPro() && localUserStore && typeof localUserStore.countUsers === 'function') { const count = await localUserStore.countUsers(); if (count >= 3) { // Burn the invite — it can't be redeemed later under a paid tier // without the host first running `addToAllowlist` to re-add the // email. This prevents invite-leak spam from filling the user // table and being immortalized. await inviteStore.accept(req.params.token, { acceptedBy: null }).catch(() => {}); throw new PaymentRequiredError( 'Free tier supports up to 3 users. Upgrade to Pro to redeem this invitation.' ); } } const result = await inviteStore.accept(req.params.token, { acceptedBy: req.user ? req.user.email : null, }); if (!result.ok) { throw new ValidationError('Invitation is ' + result.reason.replace('_', ' '), 'token'); } // Authorize the email + create the user record. const invite = result.invite; const userResult = await userStore.login({ email: invite.email, ip: req.ip || '', displayName: invite.email.split('@')[0], createdBy: 'invite:' + invite.id, }); if (!userResult.ok) { throw new ValidationError('Could not create user from invite: ' + userResult.reason); } // Create session (same shape as email verify path). if (session) { session.create(req, '24h'); session.setCookie(res, '24h'); } if (req.app.locals && req.app.locals.renewCSRFToken) { req.app.locals.renewCSRFToken(res, req.secure || req.protocol === 'https'); } // Attach user to request for audit log. req.user = { id: userResult.user.id, email: userResult.user.email, role: userResult.user.role, isAdmin: userResult.user.role === 'admin', isBootstrap: false, viaProvider: 'invite', }; log.info && log.info('auth', 'invite accepted, user created', { userId: userResult.user.id, email: userResult.user.email, role: userResult.user.role, inviteId: invite.id, }); return ok(res, { message: 'Invitation accepted', user: { id: userResult.user.id, email: userResult.user.email, role: userResult.user.role, }, csrfToken: res.locals && res.locals.csrfToken, }); }, 'auth-invites-accept')); return router; };