diff --git a/plugins/rms.auth.smtp_relay/index.js b/plugins/rms.auth.smtp_relay/index.js index 8f0108a..a44614d 100644 --- a/plugins/rms.auth.smtp_relay/index.js +++ b/plugins/rms.auth.smtp_relay/index.js @@ -76,15 +76,22 @@ async function createPlugin(ctx) { html: String(payload.html || "") }; let delivered = false; + let smtpError = ""; try { delivered = await deliverViaSmtp(entry); } catch (error) { - entry.smtpError = String(error && error.message ? error.message : error); + smtpError = String(error && error.message ? error.message : error); + entry.smtpError = smtpError; } entry.delivered = delivered; entry.transport = delivered ? "smtp" : "outbox-fallback"; await ctx.appendMailOutbox(entry); - return { ok: true, delivered, transport: delivered ? "smtp" : "outbox-fallback" }; + return { + ok: true, + delivered, + transport: delivered ? "smtp" : "outbox-fallback", + smtpError: smtpError || null + }; }, async health() { const config = readTransportConfig(); diff --git a/public/app.js b/public/app.js index 737bce1..50368b0 100644 --- a/public/app.js +++ b/public/app.js @@ -11,6 +11,7 @@ let activationWatchInFlight = false; let activationPending = false; let remainingUsageTimer = null; let resumeRefreshInFlight = false; +let rotorCompassDragPointerId = null; const state = { user: null, @@ -716,6 +717,51 @@ function bindEvents() { await setOpenWebRxRotor(); }); } + if (els.rotorCompass) { + els.rotorCompass.addEventListener("pointerdown", (event) => { + if (!els.rotorTarget || els.rotorTarget.disabled) { + return; + } + if (event.pointerType === "mouse" && event.button !== 0) { + return; + } + event.preventDefault(); + rotorCompassDragPointerId = event.pointerId; + if (typeof els.rotorCompass.setPointerCapture === "function") { + try { + els.rotorCompass.setPointerCapture(event.pointerId); + } catch { + // ignore pointer capture errors + } + } + els.rotorCompass.classList.add("is-dragging"); + updateRotorTargetFromPointerEvent(event); + }); + els.rotorCompass.addEventListener("pointermove", (event) => { + if (rotorCompassDragPointerId !== event.pointerId) { + return; + } + event.preventDefault(); + updateRotorTargetFromPointerEvent(event); + }); + const stopRotorCompassDrag = (event) => { + if (rotorCompassDragPointerId !== event.pointerId) { + return; + } + if (typeof els.rotorCompass.releasePointerCapture === "function") { + try { + els.rotorCompass.releasePointerCapture(event.pointerId); + } catch { + // ignore pointer capture errors + } + } + rotorCompassDragPointerId = null; + els.rotorCompass.classList.remove("is-dragging"); + }; + els.rotorCompass.addEventListener("pointerup", stopRotorCompassDrag); + els.rotorCompass.addEventListener("pointercancel", stopRotorCompassDrag); + els.rotorCompass.addEventListener("lostpointercapture", stopRotorCompassDrag); + } if (els.openwebrxBandSetBtn) { els.openwebrxBandSetBtn.addEventListener("click", async () => { await setOpenWebRxBand(); @@ -2031,6 +2077,43 @@ async function setOpenWebRxRotor() { } } +function normalizeCompassAzimuth(value) { + return ((Number(value) % 360) + 360) % 360; +} + +function rotorTargetBounds() { + const rotor = state.openWebRx && state.openWebRx.rotor ? state.openWebRx.rotor : null; + const min = rotor && Number.isFinite(Number(rotor.min)) ? Number(rotor.min) : 0; + const max = rotor && Number.isFinite(Number(rotor.max)) ? Number(rotor.max) : 360; + return { + min: Math.min(min, max), + max: Math.max(min, max) + }; +} + +function updateRotorTargetFromPointerEvent(event) { + if (!els.rotorCompass || !els.rotorTarget) { + return; + } + const rect = els.rotorCompass.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + const pointerX = Number(event.clientX); + const pointerY = Number(event.clientY); + if (!Number.isFinite(pointerX) || !Number.isFinite(pointerY)) { + return; + } + const dx = pointerX - centerX; + const dy = pointerY - centerY; + if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) { + return; + } + const azimuth = normalizeCompassAzimuth((Math.atan2(dx, -dy) * 180) / Math.PI); + const bounds = rotorTargetBounds(); + const clamped = Math.min(bounds.max, Math.max(bounds.min, Math.round(azimuth))); + els.rotorTarget.value = String(clamped); +} + function renderRotorState() { if (!els.rotorCurrent) { return; diff --git a/public/index.html b/public/index.html index d35247a..e2c8bb8 100644 --- a/public/index.html +++ b/public/index.html @@ -204,7 +204,7 @@ -
+
0 90 180 diff --git a/public/styles.css b/public/styles.css index 80c5fbf..9ec387d 100644 --- a/public/styles.css +++ b/public/styles.css @@ -510,6 +510,13 @@ button:disabled { background: radial-gradient(circle at 35% 30%, rgba(255,255,255,0.1), rgba(15,23,42,0.88) 65%); box-shadow: inset 0 0 0 1px rgba(255,255,255,0.05); flex: 0 0 auto; + cursor: crosshair; + touch-action: none; + user-select: none; +} + +.rotor-compass.is-dragging { + cursor: grabbing; } .rotor-mark { diff --git a/server/index.js b/server/index.js index 31f4f23..82f92c3 100644 --- a/server/index.js +++ b/server/index.js @@ -1047,12 +1047,27 @@ async function handleRequestAccess(req, res, body) { actionLink: link, actionLabel: user.status === "active" ? "Jetzt einloggen" : "E-Mail bestaetigen" }); - await sendEmailMessage( - email, - message.subject, - message.text, - message.html - ); + try { + await sendEmailMessage( + email, + message.subject, + message.text, + message.html, + { strictDelivery: true } + ); + } catch (error) { + if (isChallengeDeliveryError(error)) { + await appendAudit("auth.request_access_failed", user, { + status: user.status, + requestedMethod: requestedMethod || null, + method: "fallback-mail", + code: error.code, + reason: error && error.details ? error.details.reason : null + }); + return sendError(res, 502, error.code, error.publicMessage || "E-Mail konnte nicht zugestellt werden. Bitte spaeter erneut versuchen."); + } + throw error; + } await appendAudit("auth.request_access", user, { status: user.status, requestedMethod: requestedMethod || null, @@ -1089,14 +1104,29 @@ async function handleRequestAccess(req, res, body) { actionLink: link, actionLabel: user.status === "active" ? "Jetzt einloggen" : "E-Mail bestaetigen" }); - await dispatchAuthChallenge(req, user, selectedMethod, { - type: "link", - subject: message.subject, - text: message.text, - html: message.html, - token, - link - }); + try { + await dispatchAuthChallenge(req, user, selectedMethod, { + type: "link", + subject: message.subject, + text: message.text, + html: message.html, + token, + link + }); + } catch (error) { + if (isChallengeDeliveryError(error)) { + await appendAudit("auth.request_access_failed", user, { + status: user.status, + requestedMethod: requestedMethod || null, + method: selectedMethod.id, + challengeType, + code: error.code, + reason: error && error.details ? error.details.reason : null + }); + return sendError(res, 502, error.code, error.publicMessage || "E-Mail konnte nicht zugestellt werden. Bitte spaeter erneut versuchen."); + } + throw error; + } } else if (selectedMethod.type === "otp") { const otpCode = await issueOtpChallenge(user.id, user.status === "active" ? "login" : "verify"); challengeHint = `Code wurde ueber ${selectedMethod.label} gesendet.`; @@ -1107,13 +1137,28 @@ async function handleRequestAccess(req, res, body) { text: `Dein Code lautet: ${otpCode}`, code: otpCode }); - await dispatchAuthChallenge(req, user, selectedMethod, { - type: "otp", - subject: message.subject, - text: message.text, - html: message.html, - code: otpCode - }); + try { + await dispatchAuthChallenge(req, user, selectedMethod, { + type: "otp", + subject: message.subject, + text: message.text, + html: message.html, + code: otpCode + }); + } catch (error) { + if (isChallengeDeliveryError(error)) { + await appendAudit("auth.request_access_failed", user, { + status: user.status, + requestedMethod: requestedMethod || null, + method: selectedMethod.id, + challengeType, + code: error.code, + reason: error && error.details ? error.details.reason : null + }); + return sendError(res, 502, error.code, error.publicMessage || "E-Mail konnte nicht zugestellt werden. Bitte spaeter erneut versuchen."); + } + throw error; + } } else if (selectedMethod.type === "oauth") { const purpose = user.status === "active" ? "login" : "verify"; const stateToken = await issueOauthChallenge(user.id, purpose, selectedMethod.id); @@ -2285,7 +2330,7 @@ async function dispatchAuthChallenge(req, user, method, payload) { source: "server-prelog" }); - await plugin.instance.execute("send_challenge", { + const result = await plugin.instance.execute("send_challenge", { methodId: method.id, methodType: method.type, user: sanitizeUser(user), @@ -2293,6 +2338,10 @@ async function dispatchAuthChallenge(req, user, method, payload) { origin: publicBaseUrlFor(req), payload }, { user }); + + if (result && result.delivered === false && result.smtpError) { + throw buildAuthChallengeDeliveryError(result.smtpError, method, user); + } } async function issueOtpChallenge(userId, purpose) { @@ -2472,12 +2521,29 @@ async function ensureMailOutboxInitialized() { } } -async function sendEmailMessage(to, subject, text, html = "") { +function buildAuthChallengeDeliveryError(reason, method, user) { + const error = new Error("E-Mail konnte nicht zugestellt werden"); + error.code = "auth.challenge_delivery_failed"; + error.publicMessage = "E-Mail konnte nicht zugestellt werden. Bitte spaeter erneut versuchen."; + error.details = { + reason: String(reason || "delivery-failed"), + method: method && method.id ? String(method.id) : null, + userId: user && user.id ? String(user.id) : null + }; + return error; +} + +function isChallengeDeliveryError(error) { + return Boolean(error && error.code === "auth.challenge_delivery_failed"); +} + +async function sendEmailMessage(to, subject, text, html = "", options = {}) { + const strictDelivery = Boolean(options && options.strictDelivery); const smtpMethod = listPublicAuthMethods().find((entry) => entry.id === "smtp-link"); if (smtpMethod) { const plugin = runtime.plugins.get(smtpMethod.pluginId); if (plugin && typeof plugin.instance.execute === "function") { - await plugin.instance.execute("send_challenge", { + const result = await plugin.instance.execute("send_challenge", { methodId: smtpMethod.id, methodType: smtpMethod.type, user: null, @@ -2490,9 +2556,15 @@ async function sendEmailMessage(to, subject, text, html = "") { html: String(html || "") } }, { user: null }); + if (strictDelivery && result && result.delivered === false && result.smtpError) { + throw buildAuthChallengeDeliveryError(result.smtpError, smtpMethod, null); + } return; } } + if (strictDelivery) { + throw buildAuthChallengeDeliveryError("smtp-method-unavailable", { id: "smtp-link" }, null); + } const entry = { at: new Date().toISOString(), to, diff --git a/test/auth-methods.integration.test.js b/test/auth-methods.integration.test.js index 7def689..7fbddb6 100644 --- a/test/auth-methods.integration.test.js +++ b/test/auth-methods.integration.test.js @@ -1020,6 +1020,67 @@ test("new users default to smtp-link and outbox uses smtp relay plugin", async ( } }); +test("request-access returns error when SMTP delivery fails", async (t) => { + const rootDir = path.resolve(__dirname, ".."); + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-smtp-fail-test-")); + const port = randomPort(); + const baseUrl = `http://127.0.0.1:${port}`; + + const server = spawn(process.execPath, ["server/index.js"], { + cwd: rootDir, + env: { + ...process.env, + PORT: String(port), + DATA_DIR: dataDir, + ADMIN_EMAILS: "admin@arcg.at", + PRIMARY_EMAIL_DOMAIN: "arcg.at", + PUBLIC_BASE_URL: baseUrl, + SMTP_HOST: "127.0.0.1", + SMTP_PORT: "1", + SMTP_SECURE: "false" + }, + stdio: ["ignore", "pipe", "pipe"] + }); + + let serverStdErr = ""; + server.stderr.on("data", (chunk) => { + serverStdErr += String(chunk); + }); + + t.after(async () => { + if (!server.killed) { + server.kill("SIGTERM"); + } + await fs.rm(dataDir, { recursive: true, force: true }); + }); + + await waitForServer(baseUrl); + + let deliveryError = null; + try { + await requestJson(baseUrl, "/v1/auth/request-access", { + method: "POST", + body: { email: "deliveryfail@arcg.at" } + }); + } catch (error) { + deliveryError = error; + } + + assert.ok(deliveryError, "request-access should fail on smtp delivery error"); + assert.equal(deliveryError.status, 502); + assert.equal(deliveryError.payload.error.code, "auth.challenge_delivery_failed"); + + const outbox = await readOutbox(dataDir); + const failed = [...outbox].reverse().find((entry) => entry.to === "deliveryfail@arcg.at" && entry.via === "rms.auth.smtp_relay" && entry.delivered === false); + assert.ok(failed, "failed smtp outbox entry expected"); + assert.equal(failed.transport, "outbox-fallback"); + assert.ok(String(failed.smtpError || "").length > 0, "smtp error must be recorded"); + + if (server.exitCode !== null && server.exitCode !== 0) { + throw new Error(`Server exited unexpectedly: ${server.exitCode}\n${serverStdErr}`); + } +}); + test("public auth methods expose smtp-link first with 'per Mail' label", async (t) => { const rootDir = path.resolve(__dirname, ".."); const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "rms-public-methods-test-"));