From af48d25a34d00efa1705f94a8570761ba9533d3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=BCrz?= Date: Mon, 10 Nov 2025 15:40:03 +0100 Subject: [PATCH] Add automatic token refresh and logout --- dashboard/app.js | 91 ++++++++++++++++++++++++++++++ dashboard/index.html | 73 +++++++++++++++++++----- plugin/evdashwebserverresource.cpp | 47 ++++++++++++++- plugin/evdashwebserverresource.h | 1 + 4 files changed, 196 insertions(+), 16 deletions(-) diff --git a/dashboard/app.js b/dashboard/app.js index 7f1b5d9..b11221c 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -10,6 +10,7 @@ class DashboardApp { statusDot: document.getElementById('statusDot'), connectionStatus: document.getElementById('connectionStatus'), sessionSummary: document.getElementById('sessionSummary'), + logoutButton: document.getElementById('logoutButton'), requestTemplate: document.getElementById('requestTemplate'), responseTemplate: document.getElementById('responseTemplate'), incomingMessage: document.getElementById('incomingMessage') @@ -22,6 +23,8 @@ class DashboardApp { this.username = null; this.pendingRequests = new Map(); this.reconnectTimer = null; + this.tokenRefreshTimer = null; + this.refreshInFlight = false; this.renderStaticTemplates(); this.attachEventListeners(); @@ -35,6 +38,12 @@ class DashboardApp { this.submitLogin(); }); } + + if (this.elements.logoutButton) { + this.elements.logoutButton.addEventListener('click', () => { + this.logout(); + }); + } } renderStaticTemplates() { @@ -113,6 +122,7 @@ class DashboardApp { this.token = parsed.token; this.tokenExpiry = expiresAt; this.username = parsed.username || null; + this.scheduleTokenRefresh(); this.updateSessionSummary(); this.hideLoginOverlay(); this.connectWebSocket(); @@ -216,6 +226,8 @@ class DashboardApp { } catch (error) { console.warn('Failed to persist session', error); } + + this.scheduleTokenRefresh(); } clearSession() { @@ -225,12 +237,17 @@ class DashboardApp { this.pendingRequests.clear(); clearTimeout(this.reconnectTimer); this.reconnectTimer = null; + clearTimeout(this.tokenRefreshTimer); + this.tokenRefreshTimer = null; + this.refreshInFlight = false; try { window.localStorage.removeItem(this.sessionKey); } catch (error) { console.warn('Failed to clear session', error); } + + this.updateSessionSummary(); } connectWebSocket(resetPending = false) { @@ -412,12 +429,14 @@ class DashboardApp { if (!this.token) { this.elements.sessionSummary.textContent = 'Please sign in to start the WebSocket session.'; + this.toggleLogoutButton(false); return; } const expires = this.tokenExpiry ? this.tokenExpiry.toISOString() : 'unknown'; const username = this.username ? this.username : 'user'; this.elements.sessionSummary.textContent = `Signed in as ${username}. Token valid until ${expires}.`; + this.toggleLogoutButton(true); } showLoginOverlay(message) { @@ -474,6 +493,78 @@ class DashboardApp { return; body.classList.toggle('needs-auth', requireAuth); } + + toggleLogoutButton(visible) { + if (!this.elements.logoutButton) + return; + this.elements.logoutButton.classList.toggle('hidden', !visible); + } + + logout() { + this.clearSession(); + if (this.socket && this.socket.readyState === WebSocket.OPEN) + this.socket.close(); + this.updateConnectionStatus('Logged out', 'connecting'); + this.updateSessionSummary(); + this.showLoginOverlay('You have been logged out.'); + } + + scheduleTokenRefresh() { + clearTimeout(this.tokenRefreshTimer); + this.tokenRefreshTimer = null; + + if (!this.token || !this.tokenExpiry) + return; + + const now = Date.now(); + const expiryTime = this.tokenExpiry.getTime(); + const leadTimeMs = 60 * 1000; // refresh one minute before expiry + const delay = Math.max(expiryTime - leadTimeMs - now, 5 * 1000); + + this.tokenRefreshTimer = setTimeout(() => { + this.refreshToken(); + }, delay); + } + + async refreshToken() { + if (!this.token || this.refreshInFlight) + return; + + this.refreshInFlight = true; + + try { + const response = await fetch('/evdash/api/refresh', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ token: this.token }) + }); + + const data = await response.json(); + if (!response.ok || !data.success) + throw new Error(data && data.error ? data.error : 'refreshFailed'); + + if (!data.token || !data.expiresAt) + throw new Error('Invalid response from server.'); + + this.persistSession({ + token: data.token, + expiresAt: data.expiresAt, + username: this.username + }); + this.updateSessionSummary(); + } catch (error) { + console.warn('Token refresh failed', error); + this.clearSession(); + this.updateConnectionStatus('Authentication required', 'error'); + if (this.socket && this.socket.readyState === WebSocket.OPEN) + this.socket.close(); + this.showLoginOverlay('Session expired. Please sign in again.'); + } finally { + this.refreshInFlight = false; + } + } } window.app = new DashboardApp(); diff --git a/dashboard/index.html b/dashboard/index.html index 2a64a08..7943cc4 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -33,18 +33,33 @@ background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)); color: #ffffff; padding: 2.5rem 1.5rem 2rem; - text-align: center; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); } - header h1 { - margin: 0 0 0.5rem; + .header-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 1.5rem; + } + + .brand h1 { + margin: 0 0 0.4rem; font-weight: 600; letter-spacing: 0.02em; } - header p { + .brand p { margin: 0; + opacity: 0.9; + } + + .session-panel { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1rem 1.5rem; } main { @@ -72,6 +87,7 @@ align-items: center; gap: 0.75rem; font-weight: 600; + margin: 0; } .status-dot { @@ -247,22 +263,49 @@ .hidden { display: none !important; } + + .session-details { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 0.95rem; + } + + button.ghost { + border: 1px solid rgba(255, 255, 255, 0.55); + color: #ffffff; + background: transparent; + padding: 0.35rem 0.85rem; + border-radius: 999px; + font-size: 0.9rem; + } + + button.ghost:hover { + border-color: #ffffff; + background: rgba(255, 255, 255, 0.12); + }
-

nymea EV Dash

-

- - Awaiting login… -

+
+
+

nymea EV Dash

+

Monitor nymea EV chargers in real time.

+
+
+

+ + Awaiting login… +

+
+ Please sign in to start the WebSocket session. + +
+
+
-
-

Connection

-

Please sign in to start the WebSocket session.

-
-

API Contract

All requests follow the structure below. Use app.sendAction(action, payload) from the browser console after authentication.

@@ -303,7 +346,7 @@
- nymea EV Dash · © 2013–2025 nymea GmbH. All rights reserved. + nymea EV Dash · © 2013–2025 chargebyte GmbH. All rights reserved.
diff --git a/plugin/evdashwebserverresource.cpp b/plugin/evdashwebserverresource.cpp index 9676e7f..5ad88f7 100644 --- a/plugin/evdashwebserverresource.cpp +++ b/plugin/evdashwebserverresource.cpp @@ -29,6 +29,9 @@ HttpReply *EvDashWebServerResource::processRequest(const HttpRequest &request) if (path == basePath() + QStringLiteral("/api/login")) return handleLoginRequest(request); + if (path == basePath() + QStringLiteral("/api/refresh")) + return handleRefreshRequest(request); + // Verify methods for static content if (request.method() != HttpRequest::Get) { HttpReply *reply = HttpReply::createErrorReply(HttpReply::MethodNotAllowed); @@ -113,6 +116,49 @@ HttpReply *EvDashWebServerResource::handleLoginRequest(const HttpRequest &reques return HttpReply::createJsonReply(QJsonDocument(payload)); } +HttpReply *EvDashWebServerResource::handleRefreshRequest(const HttpRequest &request) +{ + if (request.method() != HttpRequest::Post) { + HttpReply *reply = HttpReply::createErrorReply(HttpReply::MethodNotAllowed); + reply->setHeader(HttpReply::AllowHeader, "POST"); + return reply; + } + + QJsonParseError parseError; + const QJsonDocument requestDoc = QJsonDocument::fromJson(request.payload(), &parseError); + if (parseError.error != QJsonParseError::NoError || !requestDoc.isObject()) { + QJsonObject errorPayload { + {QStringLiteral("success"), false}, + {QStringLiteral("error"), QStringLiteral("invalidRequest")} + }; + return HttpReply::createJsonReply(QJsonDocument(errorPayload), HttpReply::BadRequest); + } + + purgeExpiredTokens(); + + const QJsonObject requestObject = requestDoc.object(); + const QString token = requestObject.value(QStringLiteral("token")).toString(); + if (token.isEmpty() || !m_activeTokens.contains(token)) { + QJsonObject response { + {QStringLiteral("success"), false}, + {QStringLiteral("error"), QStringLiteral("unauthorized")} + }; + return HttpReply::createJsonReply(QJsonDocument(response), HttpReply::Unauthorized); + } + + TokenInfo info = m_activeTokens.value(token); + info.expiresAt = QDateTime::currentDateTimeUtc().addSecs(s_tokenLifetimeSeconds); + m_activeTokens.insert(token, info); + + QJsonObject payload { + {QStringLiteral("success"), true}, + {QStringLiteral("token"), token}, + {QStringLiteral("expiresAt"), info.expiresAt.toString(Qt::ISODateWithMs)} + }; + + return HttpReply::createJsonReply(QJsonDocument(payload)); +} + bool EvDashWebServerResource::verifyCredentials(const QString &username, const QString &password) const { Q_UNUSED(username) @@ -158,4 +204,3 @@ void EvDashWebServerResource::purgeExpiredTokens() ++it; } } - diff --git a/plugin/evdashwebserverresource.h b/plugin/evdashwebserverresource.h index fb3f2a6..6acc0b4 100644 --- a/plugin/evdashwebserverresource.h +++ b/plugin/evdashwebserverresource.h @@ -29,6 +29,7 @@ private: }; HttpReply *handleLoginRequest(const HttpRequest &request); + HttpReply *handleRefreshRequest(const HttpRequest &request); HttpReply *redirectToIndex(); bool verifyStaticFile(const QString &fileName);