DC-085 link-first invite — Discord-style share it however you want
Flip POST /api/v1/auth/admin/invites default to no email; always return the link. Operators copy + share via iMessage/WhatsApp/SMS/Signal/Telegram/ Discord/paste-in-email. Email becomes an opt-in checkbox (was the default). Add shareText field with pre-formatted message for one-tap paste. Stop logging raw invite URLs to error.log when SMTP is unconfigured (was just a dev fallback — link is now in the response). Frontend flips the checkbox default to unchecked and renders shareText + native share sheet button (navigator.share) alongside the raw copy-link button. 9 new tests covering default-no-send, link-always-returned, shareText-shape, opt-in SMTP send, failed-SMTP-no-leak. Full suite: 2474/2474 + 9 new = 2483.
This commit is contained in:
+16
@@ -401,6 +401,22 @@ Tickets DC-046 through DC-049 implement pluggable auth + email magic link. Sami
|
|||||||
- **result:** Shipped codex-graded A. All 49 `console.*` sites in `src/managers/update-manager.js` now route through `log.info/log.warn/log.error` from `src/utils/logging` (tag = `'update'`). Mixed-content strings extracted into structured meta payloads (`containerName`, `schedule`, `imageName`, `error.message`, `digestPrefix`, `oldImageIdPrefix`, `httpStatus`, `maxAttempts`, `attempt`, `durationMs`, `scheduledTime`, etc.) so fields are queryable instead of inlined into the message. Errors now go through `log.error(ctx, errObj)` so they land in error.log with full stack trace + context, not just stderr. 1539/1539 Jest tests pass (78/78 update-manager tests still pass). ESLint: 14 pre-existing warnings in this file unchanged, zero new warnings introduced (verified with `git stash` baseline check).
|
- **result:** Shipped codex-graded A. All 49 `console.*` sites in `src/managers/update-manager.js` now route through `log.info/log.warn/log.error` from `src/utils/logging` (tag = `'update'`). Mixed-content strings extracted into structured meta payloads (`containerName`, `schedule`, `imageName`, `error.message`, `digestPrefix`, `oldImageIdPrefix`, `httpStatus`, `maxAttempts`, `attempt`, `durationMs`, `scheduledTime`, etc.) so fields are queryable instead of inlined into the message. Errors now go through `log.error(ctx, errObj)` so they land in error.log with full stack trace + context, not just stderr. 1539/1539 Jest tests pass (78/78 update-manager tests still pass). ESLint: 14 pre-existing warnings in this file unchanged, zero new warnings introduced (verified with `git stash` baseline check).
|
||||||
|
|
||||||
|
|
||||||
|
### DC-086: Service-status flicker fix — asymmetric hysteresis on the badge
|
||||||
|
- **status:** in-progress
|
||||||
|
- **owner:** hermes
|
||||||
|
- **details:** Dashboard service badges perpetually flip between green and red for "a few seconds at a time, never stable" (Sami's report, 2026-08-20). Root cause: `src/monitoring/health-checker.js` `recordStatus()` emits `'status-check'` on EVERY probe (every 30s), and `src/websocket/dashboard-ws.js` forwards every probe as `'status-change'` to the browser with no diff. The frontend `live-events.js` then unconditionally calls `setBadge()` — which resets the icon + pill text on every event. A single transient 5xx (Caddy reload, container CPU steal, mid-flight TLS handshake, container restart during probe) flips the badge red and the next green probe flips it back. Fix: add asymmetric hysteresis in `_computeDisplayedStatus(serviceId, rawStatus)` — going DOWN requires 2 consecutive "down" probes (default `HEALTH_DOWN_THRESHOLD=2`), going UP requires only 1 (default `HEALTH_UP_THRESHOLD=1`). History + `consecutiveFailures` still record raw probe results (operators want full fidelity for postmortems); only the dashboard broadcast is filtered. `getCurrentStatus()` now returns the displayed status so a page reload shows the same badge as the live SSE stream. Both thresholds are env-var configurable so operators can tune. New tests in `__tests__/health-checker-hysteresis.test.js` cover: first probe emits; second probe same-status does NOT re-emit; one-down-then-up keeps green; two-down flips to red; one-up after down flips back to green; `getCurrentStatus` returns displayed not raw. Effort: ~30 min. Risk: low — pure behavior filter, no schema breaks, all 63 existing health-checker tests must stay green.
|
||||||
|
- **impact:** Operators stop seeing perpetual red/green flicker on healthy services. Real outages still get flagged (2 consecutive 30s probes = ~60s before badge flips red, which is still faster than a human notices). Background probe history is unchanged so postmortem analysis still works.
|
||||||
|
- **prerequisite:** None.
|
||||||
|
- **result:** _pending — ship + codex round_
|
||||||
|
|
||||||
|
### DC-085: Link-first invite — Discord-style "share it however you want"
|
||||||
|
- **status:** in-progress
|
||||||
|
- **owner:** hermes
|
||||||
|
- **details:** Today `POST /api/v1/auth/admin/invites` defaults to sending the invite link via SMTP; if SMTP is not configured it spams the server console with `[DC-048-DEV-INVITE-LINK]` log lines. Sami wants Discord-style: the link is always returned in the response, and email is an opt-in checkbox. Operators should be free to copy the link and share it via iMessage / SMS / WhatsApp / Telegram / Signal / Discord / paste-in-email — whatever fits. (1) Flip default `sendEmail !== false` to `sendEmail === true` in `routes/auth/admin.js` so omitting the field means "no email, just hand me the link." (2) Stop logging the raw invite URL to error.log when SMTP is unconfigured — that path was only useful when there was no UI way to grab the link; now there is. (3) Add a `shareText` field to the response: `"Join my DashCaddy as <role> — <acceptUrl> — expires in Nh."` for one-tap paste into any messenger. (4) Frontend: `status/js/admin.js` `_renderInviteForm` flips the "Send email" checkbox default to **unchecked**, updates `_renderIssuedInviteBanner` to show both the raw link AND the shareText (with its own copy button + `navigator.share()` native share-sheet button where available). (5) New tests in `__tests__/admin-invites.test.js` covering: default sendEmail=false (no SMTP send attempted, no console log); `sendEmail: true` triggers SMTP send; `shareText` is present and well-formed; `acceptUrl` is always returned; expired sendEmail path doesn't leak token to logs. Effort: ~1 hr. Risk: low — pure behavior flip + UI additive change.
|
||||||
|
- **impact:** Closes the friction between "host wants to add a friend" and "host has to configure SMTP first." Mirrors Discord/Slack/Linear invite flows where the link IS the deliverable. No new tier changes, no schema breaks.
|
||||||
|
- **prerequisite:** DC-048 (invite store + admin route), DC-052 (Pro gate stays).
|
||||||
|
- **result:** _pending — ship + codex round_
|
||||||
|
|
||||||
### DC-084: Remove redundant active Caddy health check from `arch.sami` site — eliminate 6 syslog spam lines/min
|
### DC-084: Remove redundant active Caddy health check from `arch.sami` site — eliminate 6 syslog spam lines/min
|
||||||
- **status:** done
|
- **status:** done
|
||||||
- **owner:** hermes
|
- **owner:** hermes
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* Tests for DC-085: link-first invite (Discord-style "share it however you want").
|
||||||
|
*
|
||||||
|
* - default sendEmail omission = no email sent, link returned, no token in logs
|
||||||
|
* - sendEmail:true triggers SMTP send when configured
|
||||||
|
* - sendEmail:true + SMTP unconfigured = deliveredVia:'failed', no token leaked
|
||||||
|
* - shareText field present and well-formed in every response
|
||||||
|
* - acceptUrl always present (regardless of sendEmail)
|
||||||
|
* - role + ttl validation unchanged from DC-048
|
||||||
|
*
|
||||||
|
* Strategy: drive the route handler directly with mock req/res, mount the admin
|
||||||
|
* router against an isolated userStore + inviteStore + email-sender stub.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
function _tmpDir() {
|
||||||
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-admin-invites-test-'));
|
||||||
|
}
|
||||||
|
function _cleanup(dir) {
|
||||||
|
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stub email-sender so we can assert "was it called?" without an SMTP server.
|
||||||
|
// NOTE: the variable name MUST start with `mock` so Jest's hoisted `jest.mock()`
|
||||||
|
// call is allowed to reference it (Babel guard against out-of-scope access).
|
||||||
|
const mockEmailSender = {
|
||||||
|
isConfigured: jest.fn(() => false),
|
||||||
|
sendEmail: jest.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
jest.mock('../src/auth/providers/email-sender', () => mockEmailSender);
|
||||||
|
|
||||||
|
describe('DC-085: link-first admin invites', () => {
|
||||||
|
let dir, app, request;
|
||||||
|
let logCalls; // captured { level, msg, meta } from our fake log
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
dir = _tmpDir();
|
||||||
|
logCalls = [];
|
||||||
|
|
||||||
|
// Set up email auth enable flag so userStore mounts.
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
|
||||||
|
const { createUserStore } = require('../src/security/user-store');
|
||||||
|
const userStore = createUserStore({ dataDir: dir });
|
||||||
|
|
||||||
|
// Bootstrap the admin so we have a session-attributable user.
|
||||||
|
await userStore.login({ email: 'admin@sami-host.me' });
|
||||||
|
|
||||||
|
// Build a tiny Express app with the admin router mounted, but skip the
|
||||||
|
// global auth gate (we inject req.user directly).
|
||||||
|
const adminRouter = require('../routes/auth/admin')({
|
||||||
|
asyncHandler: (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next),
|
||||||
|
errorResponse: (_res, code, msg) => ({ status: code, msg }),
|
||||||
|
log: {
|
||||||
|
info: (topic, msg, meta) => logCalls.push({ level: 'info', topic, msg, meta }),
|
||||||
|
warn: (topic, msg, meta) => logCalls.push({ level: 'warn', topic, msg, meta }),
|
||||||
|
error: (topic, msg, meta) => logCalls.push({ level: 'error', topic, msg, meta }),
|
||||||
|
},
|
||||||
|
session: { isSessionValid: () => true, create: () => {}, setCookie: () => {} },
|
||||||
|
dataDir: dir,
|
||||||
|
});
|
||||||
|
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
// Inject req.user = admin so /admin/* passes the role gate.
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
req.user = { id: 'admin-id', email: 'admin@sami-host.me', role: 'admin' };
|
||||||
|
req.app.locals = req.app.locals || {};
|
||||||
|
req.app.locals.siteConfig = {}; // no publicBaseUrl — route uses req.headers
|
||||||
|
req.app.locals.emailConfig = null; // SMTP not configured by default
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use('/api/v1/auth', adminRouter);
|
||||||
|
// Error handler — last in chain.
|
||||||
|
app.use((err, _req, res, _next) => {
|
||||||
|
const code = (err && err.statusCode) || 500;
|
||||||
|
res.status(code).json({
|
||||||
|
success: false,
|
||||||
|
error: err && err.message,
|
||||||
|
code: err && err.code,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
request = require('supertest');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => _cleanup(dir));
|
||||||
|
|
||||||
|
test('default sendEmail (omitted) returns link and does NOT send email', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/auth/admin/invites')
|
||||||
|
.send({ email: 'friend@example.com', role: 'operator' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
expect(mockEmailSender.sendEmail).not.toHaveBeenCalled();
|
||||||
|
expect(res.body.acceptUrl).toMatch(/\/api\/v1\/auth\/invites\/[^/]+\/accept$/);
|
||||||
|
expect(res.body.deliveredVia).toBe('manual');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default sendEmail does NOT log raw token to server log', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/auth/admin/invites')
|
||||||
|
.send({ email: 'friend@example.com', role: 'operator' });
|
||||||
|
|
||||||
|
const acceptUrl = res.body.acceptUrl;
|
||||||
|
// Extract the token from the URL and verify it does NOT appear in any log call.
|
||||||
|
const token = acceptUrl.match(/invites\/([^/]+)\/accept/)[1];
|
||||||
|
const tokenLeaked = logCalls.some(c =>
|
||||||
|
typeof c.msg === 'string' && c.msg.includes(token)
|
||||||
|
);
|
||||||
|
expect(tokenLeaked).toBe(false);
|
||||||
|
|
||||||
|
// Also assert no log entry mentions the URL verbatim (the old
|
||||||
|
// `[DC-048-DEV-INVITE-LINK] url=...` spam).
|
||||||
|
const oldSpam = logCalls.find(c =>
|
||||||
|
typeof c.msg === 'string' && c.msg.includes('[DC-048-DEV-INVITE-LINK]')
|
||||||
|
);
|
||||||
|
expect(oldSpam).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shareText is present and well-formed in every response', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/auth/admin/invites')
|
||||||
|
.send({ email: 'friend@example.com', role: 'operator', ttlHours: 24 });
|
||||||
|
|
||||||
|
expect(res.body.shareText).toBeDefined();
|
||||||
|
expect(res.body.shareText).toContain('Join my DashCaddy');
|
||||||
|
expect(res.body.shareText).toContain('operator');
|
||||||
|
expect(res.body.shareText).toContain(res.body.acceptUrl);
|
||||||
|
expect(res.body.shareText).toContain('expires in 24h');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('acceptUrl is always returned regardless of sendEmail', async () => {
|
||||||
|
const r1 = await request(app)
|
||||||
|
.post('/api/v1/auth/admin/invites')
|
||||||
|
.send({ email: 'a@x.com', sendEmail: false });
|
||||||
|
const r2 = await request(app)
|
||||||
|
.post('/api/v1/auth/admin/invites')
|
||||||
|
.send({ email: 'b@x.com' });
|
||||||
|
expect(r1.body.acceptUrl).toBeTruthy();
|
||||||
|
expect(r2.body.acceptUrl).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sendEmail: true triggers SMTP send when configured', async () => {
|
||||||
|
// Build a SECOND app instance where emailConfig is a real-looking object,
|
||||||
|
// so isConfigured() returns true. The first app uses emailConfig=null.
|
||||||
|
mockEmailSender.isConfigured.mockReturnValueOnce(true);
|
||||||
|
mockEmailSender.sendEmail.mockResolvedValueOnce(undefined);
|
||||||
|
const app2 = express();
|
||||||
|
app2.use(express.json());
|
||||||
|
app2.use((req, _res, next) => {
|
||||||
|
req.user = { id: 'admin-id', email: 'admin@sami-host.me', role: 'admin' };
|
||||||
|
req.app.locals = req.app.locals || {};
|
||||||
|
req.app.locals.siteConfig = {};
|
||||||
|
req.app.locals.emailConfig = { host: 'smtp.test', from: 'noreply@test' };
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
const { createUserStore } = require('../src/security/user-store');
|
||||||
|
const userStore2 = createUserStore({ dataDir: dir });
|
||||||
|
await userStore2.login({ email: 'admin@sami-host.me' });
|
||||||
|
const router2 = require('../routes/auth/admin')({
|
||||||
|
asyncHandler: (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next),
|
||||||
|
errorResponse: (_res, code, msg) => ({ status: code, msg }),
|
||||||
|
log: { info() {}, warn: (t, m, meta) => logCalls.push({ level: 'warn', topic: t, msg: m, meta }), error() {} },
|
||||||
|
session: { isSessionValid: () => true, create: () => {}, setCookie: () => {} },
|
||||||
|
dataDir: dir,
|
||||||
|
});
|
||||||
|
app2.use('/api/v1/auth', router2);
|
||||||
|
|
||||||
|
const res = await request(app2)
|
||||||
|
.post('/api/v1/auth/admin/invites')
|
||||||
|
.send({ email: 'friend@example.com', role: 'viewer', sendEmail: true });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(mockEmailSender.sendEmail).toHaveBeenCalledTimes(1);
|
||||||
|
const [_cfg, to, subject, text, html] = mockEmailSender.sendEmail.mock.calls[0];
|
||||||
|
expect(to).toBe('friend@example.com');
|
||||||
|
expect(subject).toMatch(/invited/i);
|
||||||
|
expect(text).toContain(res.body.acceptUrl);
|
||||||
|
expect(html).toContain(res.body.acceptUrl);
|
||||||
|
expect(res.body.deliveredVia).toBe('email');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sendEmail: true + SMTP unconfigured returns deliveredVia:failed and does NOT leak token', async () => {
|
||||||
|
mockEmailSender.isConfigured.mockReturnValueOnce(false);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/auth/admin/invites')
|
||||||
|
.send({ email: 'friend@example.com', role: 'operator', sendEmail: true });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(mockEmailSender.sendEmail).not.toHaveBeenCalled();
|
||||||
|
expect(res.body.deliveredVia).toBe('failed');
|
||||||
|
// acceptUrl + shareText still present so the operator can share manually.
|
||||||
|
expect(res.body.acceptUrl).toBeTruthy();
|
||||||
|
expect(res.body.shareText).toBeTruthy();
|
||||||
|
// Token does NOT appear in any log call.
|
||||||
|
const token = res.body.acceptUrl.match(/invites\/([^/]+)\/accept/)[1];
|
||||||
|
const tokenLeaked = logCalls.some(c =>
|
||||||
|
typeof c.msg === 'string' && c.msg.includes(token)
|
||||||
|
);
|
||||||
|
expect(tokenLeaked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid role silently defaults to operator (DC-048 behavior preserved)', async () => {
|
||||||
|
// DC-048: the route's `(role && VALID_ROLES.has(role)) ? role : 'operator'`
|
||||||
|
// silently substitutes default rather than throwing. This test pins that
|
||||||
|
// behavior so a future "strict role validation" change is a deliberate
|
||||||
|
// decision, not a silent regression.
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/auth/admin/invites')
|
||||||
|
.send({ email: 'a@x.com', role: 'superuser' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.role).toBe('operator');
|
||||||
|
expect(mockEmailSender.sendEmail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('email validation: missing email still rejected', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/auth/admin/invites')
|
||||||
|
.send({ role: 'operator' });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(mockEmailSender.sendEmail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ttlHours: 1 still produces shareText with correct expiry wording', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/auth/admin/invites')
|
||||||
|
.send({ email: 'a@x.com', ttlHours: 1 });
|
||||||
|
expect(res.body.shareText).toContain('expires in 1h');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,23 +32,6 @@ const emailSender = require('../../src/auth/providers/email-sender');
|
|||||||
const { ValidationError, NotFoundError, ForbiddenError, PaymentRequiredError } = require('../../src/utilities/errors');
|
const { ValidationError, NotFoundError, ForbiddenError, PaymentRequiredError } = require('../../src/utilities/errors');
|
||||||
const { ok, successMessage } = require('../../src/utils/responses');
|
const { ok, successMessage } = require('../../src/utils/responses');
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the URL an invitee should click. Mirrors EmailMagicLinkProvider's
|
|
||||||
* _resolvePublicUrl logic — kept duplicated (not extracted) because the two
|
|
||||||
* callers have slightly different link paths and the duplication is smaller
|
|
||||||
* than the abstraction would be.
|
|
||||||
*/
|
|
||||||
function _buildInviteUrl(req, siteConfig, token) {
|
|
||||||
if (siteConfig && siteConfig.publicBaseUrl) {
|
|
||||||
return siteConfig.publicBaseUrl.replace(/\/+$/, '') +
|
|
||||||
'/api/v1/auth/invites/' + encodeURIComponent(token) + '/accept';
|
|
||||||
}
|
|
||||||
const proto = (req.headers && req.headers['x-forwarded-proto']) || (req.protocol || 'https');
|
|
||||||
const host = (req.headers && (req.headers['x-forwarded-host'] || req.headers.host))
|
|
||||||
|| (siteConfig && siteConfig.dashboardHost) || 'localhost:3001';
|
|
||||||
return `${proto}://${host}/api/v1/auth/invites/${encodeURIComponent(token)}/accept`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _requireAdmin(req, _res, next) {
|
function _requireAdmin(req, _res, next) {
|
||||||
if (!req.user || req.user.role !== 'admin') {
|
if (!req.user || req.user.role !== 'admin') {
|
||||||
return next(new ForbiddenError('Admin role required'));
|
return next(new ForbiddenError('Admin role required'));
|
||||||
@@ -240,11 +223,21 @@ module.exports = function({ asyncHandler, errorResponse, log, session, dataDir }
|
|||||||
});
|
});
|
||||||
if (!issued.ok) throw new ValidationError(issued.reason, 'email');
|
if (!issued.ok) throw new ValidationError(issued.reason, 'email');
|
||||||
|
|
||||||
let deliveredVia = 'none';
|
// Build the accept URL once — used both for the response and for email delivery.
|
||||||
const maskedEmail = email.replace(/(^.).+(@.*$)/, '$1***$2');
|
const baseUrl = (req.app.locals && req.app.locals.siteConfig && req.app.locals.siteConfig.publicBaseUrl
|
||||||
if (sendEmail !== false) {
|
? req.app.locals.siteConfig.publicBaseUrl.replace(/\/+$/, '')
|
||||||
// Best-effort send. If SMTP isn't configured, log to error.log (dev path).
|
: ((req.headers['x-forwarded-proto'] || req.protocol || 'https') + '://' +
|
||||||
const acceptUrl = _buildInviteUrl(req, /* siteConfig */ req.app.locals && req.app.locals.siteConfig, issued.token);
|
(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 ttlHoursOut = Math.round(issued.ttlMs / (60 * 60 * 1000));
|
||||||
const text = _buildEmailText({ acceptUrl, ttlHours: ttlHoursOut, role: issued.role });
|
const text = _buildEmailText({ acceptUrl, ttlHours: ttlHoursOut, role: issued.role });
|
||||||
const html = _buildEmailHtml({ acceptUrl, ttlHours: ttlHoursOut, role: issued.role });
|
const html = _buildEmailHtml({ acceptUrl, ttlHours: ttlHoursOut, role: issued.role });
|
||||||
@@ -254,33 +247,37 @@ module.exports = function({ asyncHandler, errorResponse, log, session, dataDir }
|
|||||||
await emailSender.sendEmail(smtpConfig, issued.email, 'You\'re invited to DashCaddy', text, html);
|
await emailSender.sendEmail(smtpConfig, issued.email, 'You\'re invited to DashCaddy', text, html);
|
||||||
deliveredVia = 'email';
|
deliveredVia = 'email';
|
||||||
} else {
|
} else {
|
||||||
// Dev fallback — log the raw link so operators can grab it.
|
// Operator asked for email but SMTP isn't configured. Surface the
|
||||||
log.warn && log.warn('auth-invite-dev',
|
// failure cleanly; the link is still in the response so the
|
||||||
'[DC-048-DEV-INVITE-LINK] email=' + issued.email +
|
// operator can share it manually. Do NOT log the raw URL — it
|
||||||
' role=' + issued.role + ' url=' + acceptUrl);
|
// would duplicate what's already in the response and pollute the
|
||||||
deliveredVia = 'dev-console';
|
// 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) {
|
} catch (sendErr) {
|
||||||
log.warn && log.warn('auth-invite-send',
|
log.warn && log.warn('auth-invite-send',
|
||||||
'invite send failed: ' + (sendErr.message || String(sendErr)));
|
'invite send failed: ' + (sendErr.message || String(sendErr)),
|
||||||
|
{ inviteId: issued.id });
|
||||||
deliveredVia = 'failed';
|
deliveredVia = 'failed';
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
deliveredVia = 'manual';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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, {
|
return ok(res, {
|
||||||
id: issued.id,
|
id: issued.id,
|
||||||
email: issued.email,
|
email: issued.email,
|
||||||
role: issued.role,
|
role: issued.role,
|
||||||
expiresAt: issued.expiresAt,
|
expiresAt: issued.expiresAt,
|
||||||
// The raw token is returned ONCE so the admin UI can show/copy the
|
acceptUrl,
|
||||||
// link. It is also embedded in the email when sendEmail !== false.
|
shareText,
|
||||||
acceptUrl: (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'))) +
|
|
||||||
'/api/v1/auth/invites/' + encodeURIComponent(issued.token) + '/accept',
|
|
||||||
deliveredVia,
|
deliveredVia,
|
||||||
maskedEmail,
|
maskedEmail,
|
||||||
});
|
});
|
||||||
|
|||||||
+65
-4
@@ -216,8 +216,8 @@
|
|||||||
_el('input', { name: 'ttlHours', type: 'number', min: '1', max: '168', value: '24', style: 'padding:6px;width:80px' }),
|
_el('input', { name: 'ttlHours', type: 'number', min: '1', max: '168', value: '24', style: 'padding:6px;width:80px' }),
|
||||||
));
|
));
|
||||||
form.appendChild(_el('label', { style: 'display:flex;gap:4px;align-items:center;font-size:0.85rem' },
|
form.appendChild(_el('label', { style: 'display:flex;gap:4px;align-items:center;font-size:0.85rem' },
|
||||||
_el('input', { name: 'sendEmail', type: 'checkbox', checked: true }),
|
_el('input', { name: 'sendEmail', type: 'checkbox', checked: false }),
|
||||||
_el('span', { text: 'Send email' }),
|
_el('span', { text: 'Also send via email (optional)' }),
|
||||||
));
|
));
|
||||||
form.appendChild(_el('button', { type: 'submit', class: 'btn-sm', style: 'padding:6px 12px', text: 'Issue invite' }));
|
form.appendChild(_el('button', { type: 'submit', class: 'btn-sm', style: 'padding:6px 12px', text: 'Issue invite' }));
|
||||||
container.appendChild(form);
|
container.appendChild(form);
|
||||||
@@ -297,16 +297,77 @@
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
banner.appendChild(copyBtn);
|
banner.appendChild(copyBtn);
|
||||||
if (invite.deliveredVia === 'dev-console') {
|
|
||||||
|
// DC-085: pre-formatted message for one-tap paste into iMessage / WhatsApp /
|
||||||
|
// Telegram / SMS / Signal / Discord / paste-into-email. The operator can
|
||||||
|
// copy this as a sentence instead of dealing with the raw URL.
|
||||||
|
if (invite.shareText) {
|
||||||
|
const shareBlock = _el('div', { style: 'margin-top:12px' });
|
||||||
|
shareBlock.appendChild(_el('div', {
|
||||||
|
style: 'font-size:0.8rem;color:#86efac;margin-bottom:4px',
|
||||||
|
text: 'Share this message:',
|
||||||
|
}));
|
||||||
|
shareBlock.appendChild(_el('div', {
|
||||||
|
style: 'padding:8px;background:#000;border-radius:4px;color:#d1fae5;white-space:pre-wrap',
|
||||||
|
text: invite.shareText,
|
||||||
|
}));
|
||||||
|
const shareActions = _el('div', { style: 'margin-top:6px;display:flex;gap:6px;flex-wrap:wrap' });
|
||||||
|
const copyTextBtn = _el('button', {
|
||||||
|
class: 'btn-sm', style: 'padding:4px 10px',
|
||||||
|
text: 'Copy message',
|
||||||
|
onclick: async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(invite.shareText);
|
||||||
|
copyTextBtn.textContent = 'Copied!';
|
||||||
|
setTimeout(() => { copyTextBtn.textContent = 'Copy message'; }, 2000);
|
||||||
|
} catch (e) {
|
||||||
|
window.errorHandler && window.errorHandler.show('Clipboard blocked: select the text manually.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
shareActions.appendChild(copyTextBtn);
|
||||||
|
// Native share sheet on mobile / supported browsers. Falls back silently
|
||||||
|
// (the copy buttons cover the same intent).
|
||||||
|
if (typeof navigator !== 'undefined' && typeof navigator.share === 'function') {
|
||||||
|
const nativeShareBtn = _el('button', {
|
||||||
|
class: 'btn-sm', style: 'padding:4px 10px',
|
||||||
|
text: 'Share via…',
|
||||||
|
onclick: async () => {
|
||||||
|
try {
|
||||||
|
await navigator.share({
|
||||||
|
title: 'DashCaddy invite',
|
||||||
|
text: invite.shareText,
|
||||||
|
url: invite.acceptUrl,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// User-cancelled throws AbortError — that's fine, just stay quiet.
|
||||||
|
if (e && e.name && e.name !== 'AbortError') {
|
||||||
|
window.errorHandler && window.errorHandler.show('Share failed: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
shareActions.appendChild(nativeShareBtn);
|
||||||
|
}
|
||||||
|
shareBlock.appendChild(shareActions);
|
||||||
|
banner.appendChild(shareBlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invite.deliveredVia === 'failed') {
|
||||||
banner.appendChild(_el('p', {
|
banner.appendChild(_el('p', {
|
||||||
style: 'margin-top:8px;color:#fbbf24;font-size:0.8rem',
|
style: 'margin-top:8px;color:#fbbf24;font-size:0.8rem',
|
||||||
text: 'SMTP not configured — the invite was logged to the server console (search for [DC-048-DEV-INVITE-LINK]).',
|
text: 'Email could not be sent (SMTP not configured). Share the link above instead — it works the same way.',
|
||||||
}));
|
}));
|
||||||
} else if (invite.deliveredVia === 'email') {
|
} else if (invite.deliveredVia === 'email') {
|
||||||
banner.appendChild(_el('p', {
|
banner.appendChild(_el('p', {
|
||||||
style: 'margin-top:8px;color:#86efac;font-size:0.8rem',
|
style: 'margin-top:8px;color:#86efac;font-size:0.8rem',
|
||||||
text: 'Email sent to ' + invite.email + '.',
|
text: 'Email sent to ' + invite.email + '.',
|
||||||
}));
|
}));
|
||||||
|
} else if (invite.deliveredVia === 'manual') {
|
||||||
|
banner.appendChild(_el('p', {
|
||||||
|
style: 'margin-top:8px;color:#86efac;font-size:0.8rem',
|
||||||
|
text: 'Share the link above via text, chat, or any messenger.',
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
parent.appendChild(banner);
|
parent.appendChild(banner);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user