diff --git a/README.md b/README.md index 5268a58..3403adb 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,16 @@ Auth - `rms.auth.otp_email`: OTP challenge delivery through email - `rms.auth.oauth`: OAuth/OIDC authorization-code login (disabled by default; configurable authorize/token/userinfo/client settings) +Google example (plugin settings): +- `authorizeUrl`: `https://accounts.google.com/o/oauth2/v2/auth` +- `tokenUrl`: `https://oauth2.googleapis.com/token` +- `userInfoUrl`: `https://openidconnect.googleapis.com/v1/userinfo` +- `scope`: `openid email profile` +- `emailField`: `email` +- `clientId`: `.apps.googleusercontent.com` +- `clientSecret`: `` +- `redirectUri`: `/v1/auth/oauth/callback` (or leave empty to auto-derive) + Station & Access - `rms.station.shell`: station activation/deactivation command execution - `rms.station.access.policy`: export effective access list for OpenWebRX policies diff --git a/public/app.js b/public/app.js index 328a9af..5a455e4 100644 --- a/public/app.js +++ b/public/app.js @@ -1443,6 +1443,21 @@ async function cancelOwnReservation() { } } +async function cancelReservationByUserId(userId) { + const normalized = String(userId || "").trim(); + if (!normalized) { + return; + } + clearMessages("status"); + try { + await api(`/v1/station/reservations/${encodeURIComponent(normalized)}`, { method: "DELETE" }); + renderMessage(els.reservationMessage, "Reservierung geloescht", false, true); + await refreshStatus(); + } catch (error) { + renderMessage(els.reservationMessage, error.message, true); + } +} + async function openOpenWebRxSession() { clearMessages("status"); setOpenWebRxBusy(true); @@ -2944,8 +2959,23 @@ function renderReservationQueue(status) { left.appendChild(title); left.appendChild(details); + const right = document.createElement("div"); + right.className = "actions"; + right.appendChild(pill); + if (isAdmin() && entry && entry.userId) { + const adminDeleteBtn = document.createElement("button"); + adminDeleteBtn.type = "button"; + adminDeleteBtn.className = "ghost-btn danger"; + adminDeleteBtn.textContent = translateLiteral("Reservierung loeschen"); + adminDeleteBtn.title = translateLiteral("Reservierung dieses Benutzers loeschen"); + adminDeleteBtn.addEventListener("click", async () => { + await cancelReservationByUserId(entry.userId); + }); + right.appendChild(adminDeleteBtn); + } + row.appendChild(left); - row.appendChild(pill); + row.appendChild(right); list.appendChild(row); const isMine = loggedIn diff --git a/public/i18n/de.json b/public/i18n/de.json index 43ce7ee..616bdee 100644 --- a/public/i18n/de.json +++ b/public/i18n/de.json @@ -96,9 +96,12 @@ "Station freigegeben": "Station freigegeben", "Reservierung gespeichert": "Reservierung gespeichert", "Reservierung entfernt": "Reservierung entfernt", + "Reservierung geloescht": "Reservierung geloescht", "Um die Station fuer andere freizugeben muss die Reservierung geloescht werden.": "Um die Station fuer andere freizugeben muss die Reservierung geloescht werden.", "Noch keine Reservierungen vorhanden.": "Noch keine Reservierungen vorhanden.", "Meine Reservierung loeschen": "Meine Reservierung loeschen", + "Reservierung loeschen": "Reservierung loeschen", + "Reservierung dieses Benutzers loeschen": "Reservierung dieses Benutzers loeschen", "Aktiver Slot": "Aktiver Slot", "per Mail": "per Mail", "Keine Methode verfuegbar": "Keine Methode verfuegbar", diff --git a/public/i18n/en.json b/public/i18n/en.json index 3870c10..d5dc831 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -96,9 +96,12 @@ "Station freigegeben": "Station released", "Reservierung gespeichert": "Reservation saved", "Reservierung entfernt": "Reservation removed", + "Reservierung geloescht": "Reservation deleted", "Um die Station fuer andere freizugeben muss die Reservierung geloescht werden.": "To release the station for others, your reservation must be deleted.", "Noch keine Reservierungen vorhanden.": "No reservations yet.", "Meine Reservierung loeschen": "Remove my reservation", + "Reservierung loeschen": "Delete reservation", + "Reservierung dieses Benutzers loeschen": "Delete this user's reservation", "Aktiver Slot": "Active slot", "per Mail": "by email", "Keine Methode verfuegbar": "No method available", diff --git a/server/index.js b/server/index.js index 00d9f47..da36db9 100644 --- a/server/index.js +++ b/server/index.js @@ -491,6 +491,22 @@ async function routeRequest(req, res) { return handleDeleteOwnReservation(res, auth.user); } + if (method === "DELETE" && url.pathname.startsWith("/v1/station/reservations/")) { + const auth = requireAuth(req, res); + if (!auth) return; + if (!hasRole(auth.user, ["admin"])) { + return sendError(res, 403, "auth.forbidden", "Nicht berechtigt"); + } + const encodedUserId = decodeURIComponent(url.pathname.slice("/v1/station/reservations/".length) || "").trim(); + if (!encodedUserId || encodedUserId === "next") { + return sendError(res, 400, "station.reservation.user_missing", "Benutzer-ID fuer Reservierungsloeschung fehlt"); + } + if (!enforceRateLimit(req, res, `station:reserve-delete-admin:${auth.user.id}`, config.actionRateLimit, config.actionRateWindowMs)) { + return; + } + return handleDeleteReservationByUserId(res, auth.user, encodedUserId, true); + } + if (method === "POST" && url.pathname === "/v1/openwebrx/session") { const auth = requireAuth(req, res); if (!auth) return; @@ -1752,12 +1768,40 @@ async function migratePluginSettingsDefaults() { changed = ensureVswrReportReaderSettings(dataDir) || changed; changed = ensureOpenWebRxBandmapSettings() || changed; changed = ensureMicrohamSettings() || changed; + changed = ensureOauthSettings() || changed; if (changed) { await savePluginState(); } } +function ensureOauthSettings() { + const pluginId = "rms.auth.oauth"; + const current = runtime.pluginState.settings[pluginId] && typeof runtime.pluginState.settings[pluginId] === "object" + ? { ...runtime.pluginState.settings[pluginId] } + : {}; + const next = { + ...current, + authorizeUrl: current.authorizeUrl || "https://accounts.google.com/o/oauth2/v2/auth", + tokenUrl: current.tokenUrl || "https://oauth2.googleapis.com/token", + userInfoUrl: current.userInfoUrl || "https://openidconnect.googleapis.com/v1/userinfo", + clientId: current.clientId || "YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com", + clientSecret: current.clientSecret || "YOUR_GOOGLE_CLIENT_SECRET", + scope: current.scope || "openid email profile", + redirectUri: current.redirectUri || "", + emailField: current.emailField || "email", + authStyle: current.authStyle || "body", + audience: current.audience || "", + extraAuthorizeParams: current.extraAuthorizeParams || "", + extraTokenParams: current.extraTokenParams || "" + }; + if (JSON.stringify(current) !== JSON.stringify(next)) { + runtime.pluginState.settings[pluginId] = next; + return true; + } + return false; +} + function ensureOpenWebRxBandmapSettings() { const pluginId = "rms.openwebrx.bandmap"; const current = runtime.pluginState.settings[pluginId] && typeof runtime.pluginState.settings[pluginId] === "object" @@ -3182,28 +3226,44 @@ async function handleReserveNextSlot(res, user) { } async function handleDeleteOwnReservation(res, user) { + return handleDeleteReservationByUserId(res, user, user.id, false); +} + +async function handleDeleteReservationByUserId(res, actorUser, targetUserId, adminMode = false) { return withLock(async () => { const nowMs = Date.now(); const reservations = getNormalizedStationReservations(nowMs); - const ownEntry = reservations.find((entry) => String(entry.userId || "") === String(user.id || "")); - if (!ownEntry) { - return sendError(res, 404, "station.reservation.not_found", "Keine eigene Reservierung gefunden"); + const targetId = String(targetUserId || ""); + const targetEntry = reservations.find((entry) => String(entry.userId || "") === targetId); + if (!targetEntry) { + return sendError( + res, + 404, + "station.reservation.not_found", + adminMode ? "Reservierung nicht gefunden" : "Keine eigene Reservierung gefunden" + ); } - const removed = removeReservationByUserId(reservations, user.id); + const removed = removeReservationByUserId(reservations, targetId); if (!removed.changed) { - return sendError(res, 404, "station.reservation.not_found", "Keine eigene Reservierung gefunden"); + return sendError( + res, + 404, + "station.reservation.not_found", + adminMode ? "Reservierung nicht gefunden" : "Keine eigene Reservierung gefunden" + ); } - const ownFromMs = parseIsoMs(ownEntry.from); - const ownToMs = parseIsoMs(ownEntry.to); - const deletingActiveOwnSlot = Number.isFinite(ownFromMs) - && Number.isFinite(ownToMs) - && ownFromMs <= nowMs - && nowMs < ownToMs; + const fromMs = parseIsoMs(targetEntry.from); + const toMs = parseIsoMs(targetEntry.to); + const deletingActiveSlot = Number.isFinite(fromMs) + && Number.isFinite(toMs) + && fromMs <= nowMs + && nowMs < toMs; + const targetIsCurrentOwner = runtime.station.isInUse && String(runtime.station.activeByUserId || "") === targetId; - if (deletingActiveOwnSlot && runtime.station.isInUse && String(runtime.station.activeByUserId || "") === String(user.id || "")) { + if (deletingActiveSlot && targetIsCurrentOwner) { try { - await safeShutdownStationSession(user, "reservation-delete-current"); + await safeShutdownStationSession(actorUser, "reservation-delete-current"); } catch (error) { if (error && error.code === "TX_DISABLE_FAILED") { return sendError(res, 409, "tx.disable_failed", String(error.message || error), error.details || null); @@ -3218,20 +3278,23 @@ async function handleDeleteOwnReservation(res, user) { startedAt: null, endsAt: null, reservations: removed.reservations, - manualReleaseReservationUserId: String(runtime.station.manualReleaseReservationUserId || "") === String(user && user.id ? user.id : "") + manualReleaseReservationUserId: String(runtime.station.manualReleaseReservationUserId || "") === targetId ? null : (runtime.station.manualReleaseReservationUserId || null), - manualReleaseReservationUntil: String(runtime.station.manualReleaseReservationUserId || "") === String(user && user.id ? user.id : "") + manualReleaseReservationUntil: String(runtime.station.manualReleaseReservationUserId || "") === targetId ? null : (runtime.station.manualReleaseReservationUntil || null), updatedAt: new Date().toISOString(), - lastAction: "reserve-remove-current" + lastAction: adminMode ? "reserve-remove-current-admin" : "reserve-remove-current" }; await writeJson(files.station, runtime.station); - await appendAudit("station.reserve.remove", user, { queueLength: removed.reservations.length, currentSlot: true }); - const autoActivated = await autoActivateReservationSlotIfNeededUnlocked({ - excludeUserId: user && user.id ? String(user.id) : "" + await appendAudit("station.reserve.remove", actorUser, { + queueLength: removed.reservations.length, + currentSlot: true, + targetUserId: targetId, + mode: adminMode ? "admin" : "self" }); + const autoActivated = await autoActivateReservationSlotIfNeededUnlocked({ excludeUserId: targetId }); if (!autoActivated) { broadcastEvent("station.status.changed", buildStationStatusView()); } @@ -3241,17 +3304,21 @@ async function handleDeleteOwnReservation(res, user) { runtime.station = { ...runtime.station, reservations: removed.reservations, - manualReleaseReservationUserId: String(runtime.station.manualReleaseReservationUserId || "") === String(user && user.id ? user.id : "") + manualReleaseReservationUserId: String(runtime.station.manualReleaseReservationUserId || "") === targetId ? null : (runtime.station.manualReleaseReservationUserId || null), - manualReleaseReservationUntil: String(runtime.station.manualReleaseReservationUserId || "") === String(user && user.id ? user.id : "") + manualReleaseReservationUntil: String(runtime.station.manualReleaseReservationUserId || "") === targetId ? null : (runtime.station.manualReleaseReservationUntil || null), updatedAt: new Date().toISOString(), - lastAction: "reserve-remove" + lastAction: adminMode ? "reserve-remove-admin" : "reserve-remove" }; await writeJson(files.station, runtime.station); - await appendAudit("station.reserve.remove", user, { queueLength: removed.reservations.length }); + await appendAudit("station.reserve.remove", actorUser, { + queueLength: removed.reservations.length, + targetUserId: targetId, + mode: adminMode ? "admin" : "self" + }); broadcastEvent("station.status.changed", buildStationStatusView()); return sendJson(res, 200, { ok: true, status: buildStationStatusView() }); });