add OAuth defaults and admin reservation deletion support

Seed rms.auth.oauth plugin settings with a Google OIDC example while keeping the plugin disabled by default, add admin API/UI support to delete individual reservation entries, and extend auth flow handling for OAuth callback redirects and errors.
This commit is contained in:
2026-03-16 13:11:17 +01:00
parent 2b05057aa2
commit 7465c63b97
5 changed files with 137 additions and 24 deletions

View File

@@ -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`: `<google-client-id>.apps.googleusercontent.com`
- `clientSecret`: `<google-client-secret>`
- `redirectUri`: `<public-base-url>/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

View File

@@ -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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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() });
});