Add automatic token refresh and logout
This commit is contained in:
parent
0faf077b25
commit
af48d25a34
@ -10,6 +10,7 @@ class DashboardApp {
|
|||||||
statusDot: document.getElementById('statusDot'),
|
statusDot: document.getElementById('statusDot'),
|
||||||
connectionStatus: document.getElementById('connectionStatus'),
|
connectionStatus: document.getElementById('connectionStatus'),
|
||||||
sessionSummary: document.getElementById('sessionSummary'),
|
sessionSummary: document.getElementById('sessionSummary'),
|
||||||
|
logoutButton: document.getElementById('logoutButton'),
|
||||||
requestTemplate: document.getElementById('requestTemplate'),
|
requestTemplate: document.getElementById('requestTemplate'),
|
||||||
responseTemplate: document.getElementById('responseTemplate'),
|
responseTemplate: document.getElementById('responseTemplate'),
|
||||||
incomingMessage: document.getElementById('incomingMessage')
|
incomingMessage: document.getElementById('incomingMessage')
|
||||||
@ -22,6 +23,8 @@ class DashboardApp {
|
|||||||
this.username = null;
|
this.username = null;
|
||||||
this.pendingRequests = new Map();
|
this.pendingRequests = new Map();
|
||||||
this.reconnectTimer = null;
|
this.reconnectTimer = null;
|
||||||
|
this.tokenRefreshTimer = null;
|
||||||
|
this.refreshInFlight = false;
|
||||||
|
|
||||||
this.renderStaticTemplates();
|
this.renderStaticTemplates();
|
||||||
this.attachEventListeners();
|
this.attachEventListeners();
|
||||||
@ -35,6 +38,12 @@ class DashboardApp {
|
|||||||
this.submitLogin();
|
this.submitLogin();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.elements.logoutButton) {
|
||||||
|
this.elements.logoutButton.addEventListener('click', () => {
|
||||||
|
this.logout();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
renderStaticTemplates() {
|
renderStaticTemplates() {
|
||||||
@ -113,6 +122,7 @@ class DashboardApp {
|
|||||||
this.token = parsed.token;
|
this.token = parsed.token;
|
||||||
this.tokenExpiry = expiresAt;
|
this.tokenExpiry = expiresAt;
|
||||||
this.username = parsed.username || null;
|
this.username = parsed.username || null;
|
||||||
|
this.scheduleTokenRefresh();
|
||||||
this.updateSessionSummary();
|
this.updateSessionSummary();
|
||||||
this.hideLoginOverlay();
|
this.hideLoginOverlay();
|
||||||
this.connectWebSocket();
|
this.connectWebSocket();
|
||||||
@ -216,6 +226,8 @@ class DashboardApp {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to persist session', error);
|
console.warn('Failed to persist session', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.scheduleTokenRefresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
clearSession() {
|
clearSession() {
|
||||||
@ -225,12 +237,17 @@ class DashboardApp {
|
|||||||
this.pendingRequests.clear();
|
this.pendingRequests.clear();
|
||||||
clearTimeout(this.reconnectTimer);
|
clearTimeout(this.reconnectTimer);
|
||||||
this.reconnectTimer = null;
|
this.reconnectTimer = null;
|
||||||
|
clearTimeout(this.tokenRefreshTimer);
|
||||||
|
this.tokenRefreshTimer = null;
|
||||||
|
this.refreshInFlight = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
window.localStorage.removeItem(this.sessionKey);
|
window.localStorage.removeItem(this.sessionKey);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to clear session', error);
|
console.warn('Failed to clear session', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.updateSessionSummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
connectWebSocket(resetPending = false) {
|
connectWebSocket(resetPending = false) {
|
||||||
@ -412,12 +429,14 @@ class DashboardApp {
|
|||||||
|
|
||||||
if (!this.token) {
|
if (!this.token) {
|
||||||
this.elements.sessionSummary.textContent = 'Please sign in to start the WebSocket session.';
|
this.elements.sessionSummary.textContent = 'Please sign in to start the WebSocket session.';
|
||||||
|
this.toggleLogoutButton(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const expires = this.tokenExpiry ? this.tokenExpiry.toISOString() : 'unknown';
|
const expires = this.tokenExpiry ? this.tokenExpiry.toISOString() : 'unknown';
|
||||||
const username = this.username ? this.username : 'user';
|
const username = this.username ? this.username : 'user';
|
||||||
this.elements.sessionSummary.textContent = `Signed in as ${username}. Token valid until ${expires}.`;
|
this.elements.sessionSummary.textContent = `Signed in as ${username}. Token valid until ${expires}.`;
|
||||||
|
this.toggleLogoutButton(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
showLoginOverlay(message) {
|
showLoginOverlay(message) {
|
||||||
@ -474,6 +493,78 @@ class DashboardApp {
|
|||||||
return;
|
return;
|
||||||
body.classList.toggle('needs-auth', requireAuth);
|
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();
|
window.app = new DashboardApp();
|
||||||
|
|||||||
@ -33,18 +33,33 @@
|
|||||||
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
|
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
padding: 2.5rem 1.5rem 2rem;
|
padding: 2.5rem 1.5rem 2rem;
|
||||||
text-align: center;
|
|
||||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
header h1 {
|
.header-bar {
|
||||||
margin: 0 0 0.5rem;
|
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;
|
font-weight: 600;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
header p {
|
.brand p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
@ -72,6 +87,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot {
|
.status-dot {
|
||||||
@ -247,22 +263,49 @@
|
|||||||
.hidden {
|
.hidden {
|
||||||
display: none !important;
|
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);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="needs-auth">
|
<body class="needs-auth">
|
||||||
<header>
|
<header>
|
||||||
<h1>nymea EV Dash</h1>
|
<div class="header-bar">
|
||||||
<p class="status-indicator">
|
<div class="brand">
|
||||||
<span id="statusDot" class="status-dot connecting" aria-hidden="true"></span>
|
<h1>nymea EV Dash</h1>
|
||||||
<span id="connectionStatus">Awaiting login…</span>
|
<p>Monitor nymea EV chargers in real time.</p>
|
||||||
</p>
|
</div>
|
||||||
|
<div class="session-panel">
|
||||||
|
<p class="status-indicator">
|
||||||
|
<span id="statusDot" class="status-dot connecting" aria-hidden="true"></span>
|
||||||
|
<span id="connectionStatus">Awaiting login…</span>
|
||||||
|
</p>
|
||||||
|
<div class="session-details">
|
||||||
|
<span id="sessionSummary">Please sign in to start the WebSocket session.</span>
|
||||||
|
<button type="button" id="logoutButton" class="ghost hidden">Logout</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<section class="card" aria-live="polite">
|
|
||||||
<h2>Connection</h2>
|
|
||||||
<p id="sessionSummary" class="helper-text">Please sign in to start the WebSocket session.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>API Contract</h2>
|
<h2>API Contract</h2>
|
||||||
<p class="helper-text">All requests follow the structure below. Use <code>app.sendAction(action, payload)</code> from the browser console after authentication.</p>
|
<p class="helper-text">All requests follow the structure below. Use <code>app.sendAction(action, payload)</code> from the browser console after authentication.</p>
|
||||||
@ -303,7 +346,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<span id="appVersion">nymea EV Dash</span> · © 2013–2025 nymea GmbH. All rights reserved.
|
<span id="appVersion">nymea EV Dash</span> · © 2013–2025 chargebyte GmbH. All rights reserved.
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
|
|||||||
@ -29,6 +29,9 @@ HttpReply *EvDashWebServerResource::processRequest(const HttpRequest &request)
|
|||||||
if (path == basePath() + QStringLiteral("/api/login"))
|
if (path == basePath() + QStringLiteral("/api/login"))
|
||||||
return handleLoginRequest(request);
|
return handleLoginRequest(request);
|
||||||
|
|
||||||
|
if (path == basePath() + QStringLiteral("/api/refresh"))
|
||||||
|
return handleRefreshRequest(request);
|
||||||
|
|
||||||
// Verify methods for static content
|
// Verify methods for static content
|
||||||
if (request.method() != HttpRequest::Get) {
|
if (request.method() != HttpRequest::Get) {
|
||||||
HttpReply *reply = HttpReply::createErrorReply(HttpReply::MethodNotAllowed);
|
HttpReply *reply = HttpReply::createErrorReply(HttpReply::MethodNotAllowed);
|
||||||
@ -113,6 +116,49 @@ HttpReply *EvDashWebServerResource::handleLoginRequest(const HttpRequest &reques
|
|||||||
return HttpReply::createJsonReply(QJsonDocument(payload));
|
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
|
bool EvDashWebServerResource::verifyCredentials(const QString &username, const QString &password) const
|
||||||
{
|
{
|
||||||
Q_UNUSED(username)
|
Q_UNUSED(username)
|
||||||
@ -158,4 +204,3 @@ void EvDashWebServerResource::purgeExpiredTokens()
|
|||||||
++it;
|
++it;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -29,6 +29,7 @@ private:
|
|||||||
};
|
};
|
||||||
|
|
||||||
HttpReply *handleLoginRequest(const HttpRequest &request);
|
HttpReply *handleLoginRequest(const HttpRequest &request);
|
||||||
|
HttpReply *handleRefreshRequest(const HttpRequest &request);
|
||||||
HttpReply *redirectToIndex();
|
HttpReply *redirectToIndex();
|
||||||
|
|
||||||
bool verifyStaticFile(const QString &fileName);
|
bool verifyStaticFile(const QString &fileName);
|
||||||
|
|||||||
Reference in New Issue
Block a user