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:
10
README.md
10
README.md
@@ -38,6 +38,16 @@ Auth
|
|||||||
- `rms.auth.otp_email`: OTP challenge delivery through email
|
- `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)
|
- `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
|
Station & Access
|
||||||
- `rms.station.shell`: station activation/deactivation command execution
|
- `rms.station.shell`: station activation/deactivation command execution
|
||||||
- `rms.station.access.policy`: export effective access list for OpenWebRX policies
|
- `rms.station.access.policy`: export effective access list for OpenWebRX policies
|
||||||
|
|||||||
@@ -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() {
|
async function openOpenWebRxSession() {
|
||||||
clearMessages("status");
|
clearMessages("status");
|
||||||
setOpenWebRxBusy(true);
|
setOpenWebRxBusy(true);
|
||||||
@@ -2944,8 +2959,23 @@ function renderReservationQueue(status) {
|
|||||||
|
|
||||||
left.appendChild(title);
|
left.appendChild(title);
|
||||||
left.appendChild(details);
|
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(left);
|
||||||
row.appendChild(pill);
|
row.appendChild(right);
|
||||||
list.appendChild(row);
|
list.appendChild(row);
|
||||||
|
|
||||||
const isMine = loggedIn
|
const isMine = loggedIn
|
||||||
|
|||||||
@@ -96,9 +96,12 @@
|
|||||||
"Station freigegeben": "Station freigegeben",
|
"Station freigegeben": "Station freigegeben",
|
||||||
"Reservierung gespeichert": "Reservierung gespeichert",
|
"Reservierung gespeichert": "Reservierung gespeichert",
|
||||||
"Reservierung entfernt": "Reservierung entfernt",
|
"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.",
|
"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.",
|
"Noch keine Reservierungen vorhanden.": "Noch keine Reservierungen vorhanden.",
|
||||||
"Meine Reservierung loeschen": "Meine Reservierung loeschen",
|
"Meine Reservierung loeschen": "Meine Reservierung loeschen",
|
||||||
|
"Reservierung loeschen": "Reservierung loeschen",
|
||||||
|
"Reservierung dieses Benutzers loeschen": "Reservierung dieses Benutzers loeschen",
|
||||||
"Aktiver Slot": "Aktiver Slot",
|
"Aktiver Slot": "Aktiver Slot",
|
||||||
"per Mail": "per Mail",
|
"per Mail": "per Mail",
|
||||||
"Keine Methode verfuegbar": "Keine Methode verfuegbar",
|
"Keine Methode verfuegbar": "Keine Methode verfuegbar",
|
||||||
|
|||||||
@@ -96,9 +96,12 @@
|
|||||||
"Station freigegeben": "Station released",
|
"Station freigegeben": "Station released",
|
||||||
"Reservierung gespeichert": "Reservation saved",
|
"Reservierung gespeichert": "Reservation saved",
|
||||||
"Reservierung entfernt": "Reservation removed",
|
"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.",
|
"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.",
|
"Noch keine Reservierungen vorhanden.": "No reservations yet.",
|
||||||
"Meine Reservierung loeschen": "Remove my reservation",
|
"Meine Reservierung loeschen": "Remove my reservation",
|
||||||
|
"Reservierung loeschen": "Delete reservation",
|
||||||
|
"Reservierung dieses Benutzers loeschen": "Delete this user's reservation",
|
||||||
"Aktiver Slot": "Active slot",
|
"Aktiver Slot": "Active slot",
|
||||||
"per Mail": "by email",
|
"per Mail": "by email",
|
||||||
"Keine Methode verfuegbar": "No method available",
|
"Keine Methode verfuegbar": "No method available",
|
||||||
|
|||||||
113
server/index.js
113
server/index.js
@@ -491,6 +491,22 @@ async function routeRequest(req, res) {
|
|||||||
return handleDeleteOwnReservation(res, auth.user);
|
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") {
|
if (method === "POST" && url.pathname === "/v1/openwebrx/session") {
|
||||||
const auth = requireAuth(req, res);
|
const auth = requireAuth(req, res);
|
||||||
if (!auth) return;
|
if (!auth) return;
|
||||||
@@ -1752,12 +1768,40 @@ async function migratePluginSettingsDefaults() {
|
|||||||
changed = ensureVswrReportReaderSettings(dataDir) || changed;
|
changed = ensureVswrReportReaderSettings(dataDir) || changed;
|
||||||
changed = ensureOpenWebRxBandmapSettings() || changed;
|
changed = ensureOpenWebRxBandmapSettings() || changed;
|
||||||
changed = ensureMicrohamSettings() || changed;
|
changed = ensureMicrohamSettings() || changed;
|
||||||
|
changed = ensureOauthSettings() || changed;
|
||||||
|
|
||||||
if (changed) {
|
if (changed) {
|
||||||
await savePluginState();
|
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() {
|
function ensureOpenWebRxBandmapSettings() {
|
||||||
const pluginId = "rms.openwebrx.bandmap";
|
const pluginId = "rms.openwebrx.bandmap";
|
||||||
const current = runtime.pluginState.settings[pluginId] && typeof runtime.pluginState.settings[pluginId] === "object"
|
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) {
|
async function handleDeleteOwnReservation(res, user) {
|
||||||
|
return handleDeleteReservationByUserId(res, user, user.id, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteReservationByUserId(res, actorUser, targetUserId, adminMode = false) {
|
||||||
return withLock(async () => {
|
return withLock(async () => {
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
const reservations = getNormalizedStationReservations(nowMs);
|
const reservations = getNormalizedStationReservations(nowMs);
|
||||||
const ownEntry = reservations.find((entry) => String(entry.userId || "") === String(user.id || ""));
|
const targetId = String(targetUserId || "");
|
||||||
if (!ownEntry) {
|
const targetEntry = reservations.find((entry) => String(entry.userId || "") === targetId);
|
||||||
return sendError(res, 404, "station.reservation.not_found", "Keine eigene Reservierung gefunden");
|
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) {
|
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 fromMs = parseIsoMs(targetEntry.from);
|
||||||
const ownToMs = parseIsoMs(ownEntry.to);
|
const toMs = parseIsoMs(targetEntry.to);
|
||||||
const deletingActiveOwnSlot = Number.isFinite(ownFromMs)
|
const deletingActiveSlot = Number.isFinite(fromMs)
|
||||||
&& Number.isFinite(ownToMs)
|
&& Number.isFinite(toMs)
|
||||||
&& ownFromMs <= nowMs
|
&& fromMs <= nowMs
|
||||||
&& nowMs < ownToMs;
|
&& 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 {
|
try {
|
||||||
await safeShutdownStationSession(user, "reservation-delete-current");
|
await safeShutdownStationSession(actorUser, "reservation-delete-current");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error && error.code === "TX_DISABLE_FAILED") {
|
if (error && error.code === "TX_DISABLE_FAILED") {
|
||||||
return sendError(res, 409, "tx.disable_failed", String(error.message || error), error.details || null);
|
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,
|
startedAt: null,
|
||||||
endsAt: null,
|
endsAt: null,
|
||||||
reservations: removed.reservations,
|
reservations: removed.reservations,
|
||||||
manualReleaseReservationUserId: String(runtime.station.manualReleaseReservationUserId || "") === String(user && user.id ? user.id : "")
|
manualReleaseReservationUserId: String(runtime.station.manualReleaseReservationUserId || "") === targetId
|
||||||
? null
|
? null
|
||||||
: (runtime.station.manualReleaseReservationUserId || null),
|
: (runtime.station.manualReleaseReservationUserId || null),
|
||||||
manualReleaseReservationUntil: String(runtime.station.manualReleaseReservationUserId || "") === String(user && user.id ? user.id : "")
|
manualReleaseReservationUntil: String(runtime.station.manualReleaseReservationUserId || "") === targetId
|
||||||
? null
|
? null
|
||||||
: (runtime.station.manualReleaseReservationUntil || null),
|
: (runtime.station.manualReleaseReservationUntil || null),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
lastAction: "reserve-remove-current"
|
lastAction: adminMode ? "reserve-remove-current-admin" : "reserve-remove-current"
|
||||||
};
|
};
|
||||||
await writeJson(files.station, runtime.station);
|
await writeJson(files.station, runtime.station);
|
||||||
await appendAudit("station.reserve.remove", user, { queueLength: removed.reservations.length, currentSlot: true });
|
await appendAudit("station.reserve.remove", actorUser, {
|
||||||
const autoActivated = await autoActivateReservationSlotIfNeededUnlocked({
|
queueLength: removed.reservations.length,
|
||||||
excludeUserId: user && user.id ? String(user.id) : ""
|
currentSlot: true,
|
||||||
|
targetUserId: targetId,
|
||||||
|
mode: adminMode ? "admin" : "self"
|
||||||
});
|
});
|
||||||
|
const autoActivated = await autoActivateReservationSlotIfNeededUnlocked({ excludeUserId: targetId });
|
||||||
if (!autoActivated) {
|
if (!autoActivated) {
|
||||||
broadcastEvent("station.status.changed", buildStationStatusView());
|
broadcastEvent("station.status.changed", buildStationStatusView());
|
||||||
}
|
}
|
||||||
@@ -3241,17 +3304,21 @@ async function handleDeleteOwnReservation(res, user) {
|
|||||||
runtime.station = {
|
runtime.station = {
|
||||||
...runtime.station,
|
...runtime.station,
|
||||||
reservations: removed.reservations,
|
reservations: removed.reservations,
|
||||||
manualReleaseReservationUserId: String(runtime.station.manualReleaseReservationUserId || "") === String(user && user.id ? user.id : "")
|
manualReleaseReservationUserId: String(runtime.station.manualReleaseReservationUserId || "") === targetId
|
||||||
? null
|
? null
|
||||||
: (runtime.station.manualReleaseReservationUserId || null),
|
: (runtime.station.manualReleaseReservationUserId || null),
|
||||||
manualReleaseReservationUntil: String(runtime.station.manualReleaseReservationUserId || "") === String(user && user.id ? user.id : "")
|
manualReleaseReservationUntil: String(runtime.station.manualReleaseReservationUserId || "") === targetId
|
||||||
? null
|
? null
|
||||||
: (runtime.station.manualReleaseReservationUntil || null),
|
: (runtime.station.manualReleaseReservationUntil || null),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
lastAction: "reserve-remove"
|
lastAction: adminMode ? "reserve-remove-admin" : "reserve-remove"
|
||||||
};
|
};
|
||||||
await writeJson(files.station, runtime.station);
|
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());
|
broadcastEvent("station.status.changed", buildStationStatusView());
|
||||||
return sendJson(res, 200, { ok: true, status: buildStationStatusView() });
|
return sendJson(res, 200, { ok: true, status: buildStationStatusView() });
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user