Add login and basic api workflow

This commit is contained in:
Simon Stürz 2025-11-10 15:07:11 +01:00
parent 0920b7de49
commit f251743793
8 changed files with 897 additions and 351 deletions

View File

@ -1,29 +1,45 @@
# Agent Instructions
Welcome to the nymea EV-Dash experience plugin repository. Please keep the following guidelines in mind when working on this codebase:
Welcome to the nymea EV-Dash experience plugin repository. This project ships a Qt-based experience plugin together with a small
HTML dashboard that visualises EV charger information exposed by nymea. The following notes summarise the most important pieces
you will touch while working on tasks in this repository.
## Purpose
The dashboard should should give an overview of conofugred ev chargers in the system, show the status of each ev charger, information and provide
- Deliver a secure, brandable dashboard that shows the status of configured EV chargers, their live data and actions.
- All browser interactions flow through a nymea web server resource that authenticates users and distributes data via WebSocket
push events.
## Structure
- The `plugin` directory contains the Qt c++ implementation of the experience plugin
- The `EvDashJsonHandler` class provides the JSON RPC API definition and declaration of the experience
- The `EvDashWebServerResource` represents the webserver HTTP backend and handles REST API requests and file requests starting with the path /evdash. This class provides file access to the static resources and provides secure generated data access
- The `dashboard` folder contains the webinterface, a html + js file based website representing the frontend. The interface will be compiled into the plugin using the dashboard.qrc file.
- The dashboard should be brandable, providing 3 colors and icons should allow to change the style of the webinterface
- The dashboard uses a websocket to communicate with the API interface in `EvDashEngine`.
## Repository Structure
- `plugin/`
- Contains the Qt/C++ implementation of the experience plugin.
- `EvDashWebServerResource` is responsible for HTTP handling under `/evdash`, including REST endpoints such as the login flow and
serving the compiled dashboard assets from `dashboard.qrc`.
- `EvDashEngine` runs the WebSocket server, receives JSON requests with `{ requestId, action, payload }`, authenticates clients via
session tokens and pushes live updates.
- `EvDashJsonHandler` defines the JSON-RPC schema for nymea integration (keep new actions consistent with this handler).
- `dashboard/`
- Browser-based UI written in HTML and vanilla JavaScript (`index.html`, `app.js`). The files are bundled into the plugin through
`dashboard.qrc`.
- Use CSS variables for branding (primary/secondary/accent colours, icons) so the dashboard can be themed easily.
## General workflow
- Keep pull request descriptions concise but informative, mentioning both user-visible changes and internal refactors.
- Prefer focused commits that touch related files together.
## Implementation Guidelines
- Maintain the request/response contract documented in the repository README and API discussions:
```json
{ "requestId": "uuid", "action": "ActionName", "payload": { } }
```
Every response must echo the `requestId`, set `success` and either contain `payload` or an `error` string.
- The WebSocket connection stays closed for unauthenticated clients. They must send an `authenticate` action with a valid login
token obtained via `POST /evdash/api/login`. Reject subsequent requests until the token is validated.
- When adding UI behaviour, ensure the login overlay is shown before establishing a WebSocket connection and that connection status
is clearly visible for operators.
## C++/Qt coding style
- Follow the Qt coding conventions: camelCase for method names and member variables beginning with `m_`.
- Keep `#include` directives alphabetised within their groups (project headers before Qt and system headers).
- Use Qt container classes where the rest of the code already does so.
- Use Qt logging helpers (`qCDebug(dcEvDashExperience())`, `qCWarning(dcEvDashExperience())`, etc.) for new diagnostics.
## Coding Style
- C++/Qt: follow Qt conventions (camelCase, `m_` member prefixes, grouped/alphabetised includes, Qt containers, Qt logging macros).
- JavaScript: prefer small helper functions, avoid external dependencies, keep DOM selectors at the top of the file, and document
any public methods intended for use from the developer console.
## Tests and verification
- When possible, run the relevant unit or integration tests and mention them in the final summary.
## Workflow & Testing
- Keep commits focused and group related changes across frontend/backend in the same patch where it improves traceability.
- Mention any manual or automated verification steps in commit messages and PR summaries. If tests are unavailable, explain why.
Thank you for contributing!

View File

@ -1,103 +1,470 @@
class EvDashApp {
class DashboardApp {
constructor() {
this.statusDot = document.getElementById('statusDot');
this.connectionStatus = document.getElementById('connectionStatus');
this.outgoingStructure = document.getElementById('outgoingStructure');
this.incomingMessage = document.getElementById('incomingMessage');
this.requestTemplate = {
version: '1.0',
requestId: 'uuid-v4',
action: 'ping',
payload: {}
this.elements = {
loginOverlay: document.getElementById('loginOverlay'),
loginForm: document.getElementById('loginForm'),
loginButton: document.getElementById('loginButton'),
loginError: document.getElementById('loginError'),
username: document.getElementById('username'),
password: document.getElementById('password'),
statusDot: document.getElementById('statusDot'),
connectionStatus: document.getElementById('connectionStatus'),
sessionSummary: document.getElementById('sessionSummary'),
requestTemplate: document.getElementById('requestTemplate'),
responseTemplate: document.getElementById('responseTemplate'),
incomingMessage: document.getElementById('incomingMessage')
};
this.responseTemplate = {
version: '1.0',
requestId: 'uuid-v4',
event: 'statusUpdate',
payload: {
status: 'ok',
timestamp: new Date().toISOString()
this.sessionKey = 'evdash.session';
this.socket = null;
this.token = null;
this.tokenExpiry = null;
this.username = null;
this.pendingRequests = new Map();
this.reconnectTimer = null;
this.renderStaticTemplates();
this.attachEventListeners();
this.restoreSession();
}
attachEventListeners() {
if (this.elements.loginForm) {
this.elements.loginForm.addEventListener('submit', event => {
event.preventDefault();
this.submitLogin();
});
}
}
renderStaticTemplates() {
const contract = {
login: {
method: 'POST /evdash/api/login',
payload: {
username: 'user',
password: 'secret'
}
},
websocket: {
request: {
requestId: 'uuid',
action: 'ActionName',
payload: {}
},
authenticate: {
requestId: 'uuid',
action: 'authenticate',
payload: {
token: 'issued-token'
}
}
}
};
this.renderOutgoingTemplate();
this.connect();
const responses = {
success: {
requestId: 'uuid',
success: true,
payload: {}
},
failure: {
requestId: 'uuid',
success: false,
error: 'Error message'
},
examplePing: {
requestId: 'uuid',
success: true,
payload: {
timestamp: '2025-01-12T09:30:00Z'
}
}
};
if (this.elements.requestTemplate)
this.elements.requestTemplate.textContent = JSON.stringify(contract, null, 2);
if (this.elements.responseTemplate)
this.elements.responseTemplate.textContent = JSON.stringify(responses, null, 2);
}
get websocketUrl() {
restoreSession() {
const stored = window.localStorage.getItem(this.sessionKey);
if (!stored) {
this.showLoginOverlay();
return;
}
try {
const parsed = JSON.parse(stored);
if (!parsed || !parsed.token || !parsed.expiresAt) {
this.clearSession();
this.showLoginOverlay();
return;
}
const expiresAt = new Date(parsed.expiresAt);
if (Number.isNaN(expiresAt.getTime()) || expiresAt <= new Date()) {
this.clearSession();
this.showLoginOverlay('Your session has expired. Please sign in again.');
return;
}
this.token = parsed.token;
this.tokenExpiry = expiresAt;
this.username = parsed.username || null;
this.updateSessionSummary();
this.hideLoginOverlay();
this.connectWebSocket();
} catch (error) {
console.warn('Failed to restore session', error);
this.clearSession();
this.showLoginOverlay('We could not restore your previous session. Please sign in again.');
}
}
submitLogin() {
if (!this.elements.username || !this.elements.password || !this.elements.loginButton)
return;
const username = this.elements.username.value.trim();
const password = this.elements.password.value;
if (!username || !password) {
this.showLoginError('Username and password are required.');
return;
}
this.setLoginLoading(true);
this.performLoginRequest(username, password)
.then(session => {
this.persistSession({ ...session, username });
this.hideLoginOverlay();
this.updateSessionSummary();
this.connectWebSocket(true);
})
.catch(error => {
const message = error && error.message ? error.message : 'Login failed. Please try again.';
this.showLoginError(message);
})
.finally(() => {
this.setLoginLoading(false);
if (this.elements.password)
this.elements.password.value = '';
});
}
async performLoginRequest(username, password) {
let response;
try {
response = await fetch('/evdash/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
});
} catch (networkError) {
console.warn('Login request failed', networkError);
throw new Error('Unable to reach the login endpoint. Please check your connection.');
}
let data;
try {
data = await response.json();
} catch (parseError) {
console.warn('Failed to parse login response', parseError);
throw new Error('Received an unexpected response from the server.');
}
if (!response.ok || !data.success) {
const errorCode = data && data.error ? data.error : 'unauthorized';
throw new Error(this.describeLoginError(errorCode));
}
if (!data.token || !data.expiresAt)
throw new Error('Invalid response from server.');
return {
token: data.token,
expiresAt: data.expiresAt
};
}
describeLoginError(code) {
switch (code) {
case 'invalidRequest':
return 'The login request was malformed. Please reload the page and try again.';
case 'unauthorized':
return 'The provided credentials were not accepted.';
default:
return 'Login failed. Please try again.';
}
}
persistSession(session) {
this.token = session.token;
this.tokenExpiry = new Date(session.expiresAt);
this.username = session.username || null;
try {
window.localStorage.setItem(this.sessionKey, JSON.stringify({
token: this.token,
expiresAt: this.tokenExpiry.toISOString(),
username: this.username
}));
} catch (error) {
console.warn('Failed to persist session', error);
}
}
clearSession() {
this.token = null;
this.tokenExpiry = null;
this.username = null;
this.pendingRequests.clear();
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
try {
window.localStorage.removeItem(this.sessionKey);
} catch (error) {
console.warn('Failed to clear session', error);
}
}
connectWebSocket(resetPending = false) {
if (!this.token) {
this.updateConnectionStatus('Awaiting login…', 'connecting');
return;
}
if (this.tokenExpiry && this.tokenExpiry <= new Date()) {
this.clearSession();
this.showLoginOverlay('Your session has expired. Please sign in again.');
return;
}
if (this.socket && (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING))
return;
if (resetPending)
this.pendingRequests.clear();
clearTimeout(this.reconnectTimer);
this.updateConnectionStatus('Connecting…', 'connecting');
const protocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
const hostname = window.location.hostname || 'localhost';
const host = window.location.hostname || 'localhost';
const port = 4449;
const normalizedHost = hostname.includes(':') ? `[${hostname}]` : hostname;
return `${protocol}${normalizedHost}:${port}`;
}
connect() {
this.updateStatus('Connecting…', 'connecting');
this.socket = new WebSocket(this.websocketUrl);
const normalizedHost = host.includes(':') ? `[${host}]` : host;
const url = `${protocol}${normalizedHost}:${port}`;
this.socket = new WebSocket(url);
this.socket.addEventListener('open', () => {
this.updateStatus('Connected', 'connected');
this.sendExampleMessage()
this.updateConnectionStatus('Authenticating…', 'authenticating');
this.sendAuthenticate();
});
this.socket.addEventListener('message', event => {
try {
const data = JSON.parse(event.data);
this.incomingMessage.textContent = JSON.stringify(data, null, 2);
} catch (error) {
this.incomingMessage.textContent = `Failed to parse message: ${error.message}`;
}
this.onSocketMessage(event);
});
this.socket.addEventListener('error', () => {
this.updateStatus('Connection error', 'error');
this.updateConnectionStatus('Connection error', 'error');
});
this.socket.addEventListener('close', () => {
this.updateStatus('Disconnected. Reconnecting…', 'error');
setTimeout(() => this.connect(), 3000);
this.onSocketClosed();
});
}
updateStatus(text, state) {
this.connectionStatus.textContent = text;
this.statusDot.classList.remove('connected', 'connecting', 'error');
this.statusDot.classList.add(state);
}
sendAuthenticate() {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN)
return;
renderOutgoingTemplate() {
const example = {
...this.requestTemplate,
const requestId = this.generateRequestId();
const message = {
requestId,
action: 'authenticate',
payload: {
example: true
token: this.token
}
};
this.outgoingStructure.textContent = JSON.stringify({
request: this.requestTemplate,
response: this.responseTemplate,
exampleRequest: example
}, null, 2);
this.pendingRequests.set(requestId, { type: 'authenticate' });
this.socket.send(JSON.stringify(message));
}
sendExampleMessage() {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
const message = {
...this.requestTemplate,
requestId: crypto.randomUUID ? crypto.randomUUID() : `req-${Date.now()}`,
payload: {
command: 'ping',
timestamp: new Date().toISOString()
}
};
this.socket.send(JSON.stringify(message));
return message;
onSocketMessage(event) {
let data;
try {
data = JSON.parse(event.data);
} catch (error) {
console.warn('Failed to parse WebSocket message', error);
this.elements.incomingMessage.textContent = `Failed to parse message: ${error.message}`;
return;
}
console.warn('WebSocket is not connected.');
return null;
if (this.elements.incomingMessage)
this.elements.incomingMessage.textContent = JSON.stringify(data, null, 2);
if (data.requestId && this.pendingRequests.has(data.requestId)) {
const pending = this.pendingRequests.get(data.requestId);
this.pendingRequests.delete(data.requestId);
if (pending.type === 'authenticate') {
if (data.success) {
this.onAuthenticationSucceeded();
} else {
this.onAuthenticationFailed(data.error || 'unauthorized');
}
return;
}
}
if (data.success === false && data.error === 'unauthenticated') {
this.onAuthenticationFailed('unauthenticated');
}
}
onAuthenticationSucceeded() {
this.updateConnectionStatus('Connected', 'connected');
this.updateSessionSummary();
}
onAuthenticationFailed(reason) {
const message = reason === 'unauthenticated'
? 'Your session expired. Please sign in again.'
: 'Authentication failed. Please try again.';
console.warn('Authentication failed', reason);
this.clearSession();
this.showLoginOverlay(message);
this.updateConnectionStatus('Authentication required', 'error');
if (this.socket && this.socket.readyState === WebSocket.OPEN)
this.socket.close();
}
onSocketClosed() {
this.pendingRequests.clear();
this.updateConnectionStatus('Disconnected', 'error');
if (!this.token) {
this.showLoginOverlay();
return;
}
clearTimeout(this.reconnectTimer);
this.reconnectTimer = setTimeout(() => {
this.connectWebSocket();
}, 3000);
}
sendAction(action, payload = {}) {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
console.warn('Cannot send action. WebSocket not connected.');
return null;
}
if (action !== 'authenticate' && this.pendingRequests.size && !this.isAuthenticated()) {
console.warn('Cannot send action before authentication succeeded.');
return null;
}
const requestId = this.generateRequestId();
const message = {
requestId,
action,
payload
};
this.socket.send(JSON.stringify(message));
return requestId;
}
isAuthenticated() {
for (const pending of this.pendingRequests.values()) {
if (pending.type === 'authenticate')
return false;
}
return !!this.token && !!this.socket && this.socket.readyState === WebSocket.OPEN;
}
sendPing() {
return this.sendAction('ping', { timestamp: new Date().toISOString() });
}
updateConnectionStatus(text, state) {
if (this.elements.connectionStatus)
this.elements.connectionStatus.textContent = text;
if (!this.elements.statusDot)
return;
const dot = this.elements.statusDot;
dot.classList.remove('connecting', 'connected', 'authenticating', 'error');
dot.classList.add(state);
}
updateSessionSummary() {
if (!this.elements.sessionSummary)
return;
if (!this.token) {
this.elements.sessionSummary.textContent = 'Please sign in to start the WebSocket session.';
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}.`;
}
showLoginOverlay(message) {
if (this.elements.loginOverlay)
this.elements.loginOverlay.classList.remove('hidden');
if (typeof message === 'string' && message.length > 0)
this.showLoginError(message);
else
this.hideLoginError();
if (this.elements.username)
setTimeout(() => this.elements.username.focus(), 50);
}
hideLoginOverlay() {
if (this.elements.loginOverlay)
this.elements.loginOverlay.classList.add('hidden');
this.hideLoginError();
}
showLoginError(message) {
if (!this.elements.loginError)
return;
this.elements.loginError.textContent = message;
this.elements.loginError.classList.remove('hidden');
}
hideLoginError() {
if (!this.elements.loginError)
return;
this.elements.loginError.textContent = '';
this.elements.loginError.classList.add('hidden');
}
setLoginLoading(loading) {
if (!this.elements.loginButton)
return;
this.elements.loginButton.disabled = loading;
this.elements.loginButton.textContent = loading ? 'Signing in…' : 'Sign in';
}
generateRequestId() {
if (window.crypto && window.crypto.randomUUID)
return window.crypto.randomUUID();
return `req-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
}
window.app = new EvDashApp();
window.app = new DashboardApp();

View File

@ -9,116 +9,294 @@
--primary-color: #0050a0;
--secondary-color: #00a0e0;
--accent-color: #f4b400;
--surface-color: #ffffff;
--background-color: #f5f7fa;
--text-color: #1f2d3d;
--muted-text-color: #566b84;
}
* {
box-sizing: border-box;
}
body {
font-family: "Segoe UI", Roboto, sans-serif;
margin: 0;
padding: 0;
background-color: var(--background-color);
font-family: "Segoe UI", Roboto, sans-serif;
background: var(--background-color);
color: var(--text-color);
min-height: 100vh;
display: flex;
flex-direction: column;
min-height: 100vh;
}
header {
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
color: #ffffff;
padding: 2rem 1.5rem;
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;
font-weight: 600;
letter-spacing: 0.02em;
}
header p {
margin: 0;
}
main {
flex: 1;
padding: 2rem 1.5rem;
max-width: 960px;
margin: 0 auto;
width: min(960px, 92vw);
margin: 2rem auto 3rem;
display: grid;
gap: 1.5rem;
}
.card {
background: #ffffff;
border-radius: 12px;
background: var(--surface-color);
border-radius: 14px;
padding: 1.5rem;
box-shadow: 0 4px 12px rgba(31, 45, 61, 0.08);
margin-bottom: 1.5rem;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.06);
}
.card h2 {
margin-top: 0;
font-size: 1.25rem;
}
.status-indicator {
display: inline-flex;
align-items: center;
gap: 0.5rem;
gap: 0.75rem;
font-weight: 600;
}
.status-dot {
width: 12px;
height: 12px;
width: 14px;
height: 14px;
border-radius: 50%;
background-color: #d3dce6;
transition: background-color 0.3s ease;
}
.status-dot.connected {
background-color: #2ecc71;
}
.status-dot.connecting {
background-color: #f4b400;
}
.status-dot.connected {
background-color: #2ecc71;
}
.status-dot.authenticating {
background-color: #8e44ad;
}
.status-dot.error {
background-color: #e74c3c;
}
.grid-two-column {
display: grid;
gap: 1.5rem;
}
@media (min-width: 900px) {
.grid-two-column {
grid-template-columns: 1fr 1fr;
}
}
pre {
margin: 0;
background: #f8fafc;
border-radius: 8px;
border-radius: 10px;
padding: 1rem;
overflow-x: auto;
font-size: 0.95rem;
}
footer {
text-align: center;
padding: 1rem;
background-color: #ffffff;
border-top: 1px solid #d3dce6;
font-size: 0.9rem;
code {
font-family: "Fira Code", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
}
a {
color: inherit;
footer {
text-align: center;
padding: 1.25rem 1rem;
background: #ffffff;
border-top: 1px solid #d3dce6;
font-size: 0.9rem;
color: var(--muted-text-color);
}
/* Login overlay */
.overlay {
position: fixed;
inset: 0;
background: rgba(31, 45, 61, 0.65);
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
transition: opacity 0.3s ease;
}
.overlay.hidden {
visibility: hidden;
opacity: 0;
pointer-events: none;
}
.login-panel {
background: var(--surface-color);
width: min(420px, 100%);
border-radius: 16px;
padding: 2rem;
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.18);
}
.login-panel h2 {
margin: 0 0 0.5rem;
font-size: 1.5rem;
}
.login-panel p {
margin: 0;
color: var(--muted-text-color);
}
form {
margin-top: 1.5rem;
display: grid;
gap: 1rem;
}
label {
display: grid;
gap: 0.35rem;
font-weight: 600;
}
input[type="text"],
input[type="password"] {
border-radius: 10px;
border: 1px solid #d3dce6;
padding: 0.75rem 1rem;
font-size: 1rem;
background: #f9fbfd;
color: var(--text-color);
}
input[type="text"]:focus,
input[type="password"]:focus {
outline: none;
border-color: var(--secondary-color);
box-shadow: 0 0 0 3px rgba(0, 160, 224, 0.2);
}
button {
border: none;
border-radius: 999px;
padding: 0.75rem 1.5rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
justify-self: start;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
button.primary {
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
color: #ffffff;
box-shadow: 0 10px 24px rgba(0, 80, 160, 0.26);
}
button.primary:disabled {
opacity: 0.65;
cursor: progress;
box-shadow: none;
}
button.primary:not(:disabled):hover {
transform: translateY(-1px);
box-shadow: 0 16px 32px rgba(0, 80, 160, 0.28);
}
.error-message {
background: rgba(231, 76, 60, 0.08);
border: 1px solid rgba(231, 76, 60, 0.18);
color: #c0392b;
border-radius: 10px;
padding: 0.75rem 1rem;
}
.helper-text {
font-size: 0.9rem;
color: var(--muted-text-color);
}
.hidden {
display: none !important;
}
</style>
</head>
<body>
<header>
<h1>Hello from nymea EV Dash</h1>
<h1>nymea EV Dash</h1>
<p class="status-indicator">
<span id="statusDot" class="status-dot connecting" aria-hidden="true"></span>
<span id="connectionStatus">Connecting…</span>
<span id="connectionStatus">Awaiting login</span>
</p>
</header>
<main>
<section class="card">
<h2>WebSocket Messages</h2>
<p>Outgoing JSON structure:</p>
<pre id="outgoingStructure"></pre>
<p>Last incoming JSON message:</p>
<pre id="incomingMessage">No messages received yet.</pre>
<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">
<h2>Quick Start</h2>
<p>This page establishes a bidirectional WebSocket connection to the nymea EV Dash backend. Use <code>app.sendExampleMessage()</code> in the browser console to send a sample payload.</p>
<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>
<div class="grid-two-column">
<div>
<h3>Request template</h3>
<pre id="requestTemplate"></pre>
</div>
<div>
<h3>Responses</h3>
<pre id="responseTemplate"></pre>
</div>
</div>
</section>
<section class="card" aria-live="polite">
<h2>Last message</h2>
<pre id="incomingMessage">No messages received yet.</pre>
</section>
</main>
<div id="loginOverlay" class="overlay" role="dialog" aria-modal="true" aria-labelledby="loginTitle">
<div class="login-panel">
<h2 id="loginTitle">Sign in</h2>
<p>Authenticate with your nymea credentials to obtain a session token for the dashboard.</p>
<div id="loginError" class="error-message hidden" role="alert"></div>
<form id="loginForm" autocomplete="on">
<label for="username">Username
<input type="text" id="username" name="username" autocomplete="username" required placeholder="nymea user">
</label>
<label for="password">Password
<input type="password" id="password" name="password" autocomplete="current-password" required placeholder="••••••••">
</label>
<p class="helper-text">Tokens remain valid for one hour. Reloading the dashboard reuses an active session automatically.</p>
<button type="submit" id="loginButton" class="primary">Sign in</button>
</form>
</div>
</div>
<footer>
<span id="appVersion">Version 0.1.0</span> · &copy; 20132025 nymea GmbH. All rights reserved.
<span id="appVersion">nymea EV Dash</span> · &copy; 20132025 nymea GmbH. All rights reserved.
</footer>
<script src="app.js"></script>

View File

@ -29,16 +29,19 @@
* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#include "evdashengine.h"
#include "evdashwebserverresource.h"
#include <integrations/thingmanager.h>
#include <QWebSocket>
#include <QWebSocketServer>
#include <QHostAddress>
#include <QWebSocketProtocol>
#include <QDateTime>
#include <QJsonParseError>
#include <QJsonDocument>
#include <QJsonObject>
#include <QLoggingCategory>
Q_DECLARE_LOGGING_CATEGORY(dcEvDashExperience)
@ -63,11 +66,13 @@ EvDashEngine::EvDashEngine(ThingManager *thingManager, EvDashWebServerResource *
connect(socket, &QWebSocket::disconnected, this, [this, socket](){
m_clients.removeAll(socket);
m_authenticatedClients.remove(socket);
qCDebug(dcEvDashExperience()) << "WebSocket client disconnected" << socket->peerAddress() << "Remaining clients:" << m_clients.count();
socket->deleteLater();
});
m_clients.append(socket);
m_authenticatedClients.insert(socket, QString());
qCDebug(dcEvDashExperience()) << "WebSocket client connected" << socket->peerAddress() << "Total clients:" << m_clients.count();
});
@ -91,6 +96,7 @@ EvDashEngine::~EvDashEngine()
}
m_clients.clear();
m_authenticatedClients.clear();
}
bool EvDashEngine::startWebSocket(quint16 port)
@ -123,58 +129,82 @@ void EvDashEngine::processTextMessage(QWebSocket *socket, const QString &message
if (parseError.error != QJsonParseError::NoError || !doc.isObject()) {
qCWarning(dcEvDashExperience()) << "Invalid WebSocket payload" << parseError.errorString();
QJsonObject errorReply {
{QStringLiteral("version"), QStringLiteral("1.0")},
{QStringLiteral("event"), QStringLiteral("error")},
{QStringLiteral("payload"), QJsonObject{
{QStringLiteral("message"), QStringLiteral("Invalid JSON payload")},
{QStringLiteral("details"), parseError.errorString()}
}}
};
QJsonObject errorReply = createErrorResponse(QString(), QStringLiteral("invalidPayload"));
sendReply(socket, errorReply);
return;
}
const QJsonObject response = handleApiRequest(doc.object());
const QJsonObject requestObject = doc.object();
const QString requestId = requestObject.value(QStringLiteral("requestId")).toString();
const QString action = requestObject.value(QStringLiteral("action")).toString();
if (action.isEmpty()) {
QJsonObject response = createErrorResponse(requestId, QStringLiteral("invalidAction"));
sendReply(socket, response);
return;
}
const bool isAuthenticateAction = action.compare(QStringLiteral("authenticate"), Qt::CaseInsensitive) == 0;
if (!isAuthenticateAction) {
const QString token = m_authenticatedClients.value(socket);
if (token.isEmpty()) {
QJsonObject response = createErrorResponse(requestId, QStringLiteral("unauthenticated"));
sendReply(socket, response);
socket->close(QWebSocketProtocol::CloseCodePolicyViolated, QStringLiteral("Authentication required"));
m_authenticatedClients.remove(socket);
return;
}
}
QJsonObject response = handleApiRequest(socket, requestObject);
sendReply(socket, response);
if (isAuthenticateAction && !response.value(QStringLiteral("success")).toBool()) {
socket->close(QWebSocketProtocol::CloseCodePolicyViolated, QStringLiteral("Authentication failed"));
m_authenticatedClients.remove(socket);
}
}
QJsonObject EvDashEngine::handleApiRequest(const QJsonObject &request) const
QJsonObject EvDashEngine::handleApiRequest(QWebSocket *socket, const QJsonObject &request)
{
qCDebug(dcEvDashExperience()) << "Handle API request" << request;
QJsonObject response;
response.insert(QStringLiteral("version"), request.value(QStringLiteral("version")).toString(QStringLiteral("1.0")));
const QString requestId = request.value(QStringLiteral("requestId")).toString();
if (!requestId.isEmpty())
response.insert(QStringLiteral("requestId"), requestId);
const QString action = request.value(QStringLiteral("action")).toString();
if (action.compare(QStringLiteral("authenticate"), Qt::CaseInsensitive) == 0) {
const QJsonObject payload = request.value(QStringLiteral("payload")).toObject();
const QString token = payload.value(QStringLiteral("token")).toString();
if (token.isEmpty())
return createErrorResponse(requestId, QStringLiteral("missingToken"));
if (!m_webServerResource || !m_webServerResource->validateToken(token)) {
m_authenticatedClients.remove(socket);
return createErrorResponse(requestId, QStringLiteral("unauthorized"));
}
m_authenticatedClients.insert(socket, token);
QJsonObject responsePayload {
{QStringLiteral("authenticated"), true},
{QStringLiteral("timestamp"), QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs)}
};
return createSuccessResponse(requestId, responsePayload);
}
if (action.compare(QStringLiteral("ping"), Qt::CaseInsensitive) == 0) {
response.insert(QStringLiteral("event"), QStringLiteral("statusUpdate"));
QJsonObject payload;
payload.insert(QStringLiteral("status"), QStringLiteral("ok"));
payload.insert(QStringLiteral("timestamp"), QDateTime::currentDateTimeUtc().toString(Qt::ISODateWithMs));
const QJsonObject requestPayload = request.value(QStringLiteral("payload")).toObject();
if (!requestPayload.isEmpty())
payload.insert(QStringLiteral("echo"), requestPayload);
response.insert(QStringLiteral("payload"), payload);
} else {
response.insert(QStringLiteral("event"), QStringLiteral("error"));
QJsonObject payload;
payload.insert(QStringLiteral("message"), QStringLiteral("Unknown action"));
payload.insert(QStringLiteral("action"), action);
response.insert(QStringLiteral("payload"), payload);
return createSuccessResponse(requestId, payload);
}
return response;
return createErrorResponse(requestId, QStringLiteral("unknownAction"));
}
void EvDashEngine::sendReply(QWebSocket *socket, QJsonObject response) const
@ -182,9 +212,28 @@ void EvDashEngine::sendReply(QWebSocket *socket, QJsonObject response) const
if (!socket)
return;
if (!response.contains(QStringLiteral("version")))
response.insert(QStringLiteral("version"), QStringLiteral("1.0"));
const QJsonDocument replyDoc(response);
socket->sendTextMessage(QString::fromUtf8(replyDoc.toJson(QJsonDocument::Compact)));
}
QJsonObject EvDashEngine::createSuccessResponse(const QString &requestId, const QJsonObject &payload) const
{
QJsonObject response;
if (!requestId.isEmpty())
response.insert(QStringLiteral("requestId"), requestId);
response.insert(QStringLiteral("success"), true);
response.insert(QStringLiteral("payload"), payload.isEmpty() ? QJsonObject{} : payload);
return response;
}
QJsonObject EvDashEngine::createErrorResponse(const QString &requestId, const QString &errorMessage) const
{
QJsonObject response;
if (!requestId.isEmpty())
response.insert(QStringLiteral("requestId"), requestId);
response.insert(QStringLiteral("success"), false);
response.insert(QStringLiteral("error"), errorMessage);
return response;
}

View File

@ -32,6 +32,8 @@
#define EVDASHENGINE_H
#include <QObject>
#include <QHash>
#include <QJsonObject>
class QWebSocket;
class QWebSocketServer;
@ -55,11 +57,14 @@ private:
QWebSocketServer *m_webSocketServer = nullptr;
QList<QWebSocket *> m_clients;
QHash<QWebSocket *, QString> m_authenticatedClients;
bool startWebSocket(quint16 port = 0);
void processTextMessage(QWebSocket *socket, const QString &message);
QJsonObject handleApiRequest(const QJsonObject &request) const;
QJsonObject handleApiRequest(QWebSocket *socket, const QJsonObject &request);
void sendReply(QWebSocket *socket, QJsonObject response) const;
QJsonObject createSuccessResponse(const QString &requestId, const QJsonObject &payload = {}) const;
QJsonObject createErrorResponse(const QString &requestId, const QString &errorMessage) const;
};
#endif // EVDASHENGINE_H

View File

@ -38,128 +38,7 @@ EvDashJsonHandler::EvDashJsonHandler(EvDashEngine *engine, QObject *parent):
JsonHandler{parent},
m_engine{engine}
{
// registerEnum<EnergyManager::EnergyError>();
// registerEnum<EnergyLogs::SampleRate>();
// registerObject<PowerBalanceLogEntry, PowerBalanceLogEntries>();
// registerObject<ThingPowerLogEntry, ThingPowerLogEntries>();
// QVariantMap params, returns;
// QString description;
// params.clear(); returns.clear();
// description = "Get the root meter ID. If there is no root meter set, the params will be empty.";
// returns.insert("o:rootMeterThingId", enumValueName(Uuid));
// registerMethod("GetRootMeter", description, params, returns, Types::PermissionScopeNone);
// params.clear(); returns.clear();
// description = "Set the root meter.";
// params.insert("rootMeterThingId", enumValueName(Uuid));
// returns.insert("energyError", enumRef<EnergyManager::EnergyError>());
// registerMethod("SetRootMeter", description, params, returns, Types::PermissionScopeAdmin);
// params.clear(); returns.clear();
// description = "Get the current power balance. That is, production, consumption and acquisition.";
// returns.insert("currentPowerConsumption", enumValueName(Double));
// returns.insert("currentPowerProduction", enumValueName(Double));
// returns.insert("currentPowerAcquisition", enumValueName(Double));
// returns.insert("currentPowerStorage", enumValueName(Double));
// returns.insert("totalConsumption", enumValueName(Double));
// returns.insert("totalProduction", enumValueName(Double));
// returns.insert("totalAcquisition", enumValueName(Double));
// returns.insert("totalReturn", enumValueName(Double));
// registerMethod("GetPowerBalance", description, params, returns, Types::PermissionScopeNone);
// params.clear(); returns.clear();
// description = "Get logs for the power balance. If from is not give, the log will start at the beginning of "
// "recording. If to is not given, the logs will and at the last sample for this sample rate before now.";
// params.insert("sampleRate", enumRef<EnergyLogs::SampleRate>());
// params.insert("o:from", enumValueName(Uint));
// params.insert("o:to", enumValueName(Uint));
// returns.insert("powerBalanceLogEntries", objectRef<PowerBalanceLogEntries>());
// registerMethod("GetPowerBalanceLogs", description, params, returns, Types::PermissionScopeNone);
// params.clear(); returns.clear();
// description = "Get logs for one or more things power values. If thingIds is not given, logs for all energy related "
// "things will be returned. If from is not given, the log will start at the beginning of recording. If "
// "to is not given, the logs will and at the last sample for this sample rate before now. If the parameter "
// "\"includeCurrent\" is set to true, the result will contain the newest log entries available, regardless "
// "of the sample rate (that is, 1 minute). This may be useful to calculate the difference to the newest "
// "entry of the fetched sample rate and the current values to display the live value until the current sample "
// "is completed.";
// params.insert("sampleRate", enumRef<EnergyLogs::SampleRate>());
// params.insert("o:thingIds", QVariantList() << enumValueName(Uuid));
// params.insert("o:from", enumValueName(Uint));
// params.insert("o:to", enumValueName(Uint));
// params.insert("o:includeCurrent", enumValueName(Bool));
// returns.insert("o:currentEntries", objectRef<ThingPowerLogEntries>());
// returns.insert("thingPowerLogEntries", objectRef<ThingPowerLogEntries>());
// registerMethod("GetThingPowerLogs", description, params, returns, Types::PermissionScopeNone);
// params.clear();
// description = "Emitted whenever the root meter id changes. If the root meter has been unset, the params will be empty.";
// params.insert("o:rootMeterThingId", enumValueName(Uuid));
// registerNotification("RootMeterChanged", description, params);
// params.clear();
// description = "Emitted whenever the energy balance changes. That is, when the current consumption, production or "
// "acquisition changes. Typically they will all change at the same time.";
// params.insert("currentPowerConsumption", enumValueName(Double));
// params.insert("currentPowerProduction", enumValueName(Double));
// params.insert("currentPowerAcquisition", enumValueName(Double));
// params.insert("currentPowerStorage", enumValueName(Double));
// params.insert("totalConsumption", enumValueName(Double));
// params.insert("totalProduction", enumValueName(Double));
// params.insert("totalAcquisition", enumValueName(Double));
// params.insert("totalReturn", enumValueName(Double));
// registerNotification("PowerBalanceChanged", description, params);
// params.clear();
// description = "Emitted whenever an entry is added to the power balance log.";
// params.insert("sampleRate", enumRef<EnergyLogs::SampleRate>());
// params.insert("powerBalanceLogEntry", objectRef<PowerBalanceLogEntry>());
// registerNotification("PowerBalanceLogEntryAdded", description, params);
// params.clear();
// description = "Emitted whenever an entry is added to the thing power log.";
// params.insert("sampleRate", enumRef<EnergyLogs::SampleRate>());
// params.insert("thingPowerLogEntry", objectRef<ThingPowerLogEntry>());
// registerNotification("ThingPowerLogEntryAdded", description, params);
// connect(m_energyManager, &EnergyManager::rootMeterChanged, this, [=](){
// QVariantMap params;
// if (m_energyManager->rootMeter()) {
// params.insert("rootMeterThingId", m_energyManager->rootMeter()->id());
// }
// emit RootMeterChanged(params);
// });
// connect(m_energyManager, &EnergyManager::powerBalanceChanged, this, [=](){
// QVariantMap params;
// params.insert("currentPowerConsumption", m_energyManager->currentPowerConsumption());
// params.insert("currentPowerProduction", m_energyManager->currentPowerProduction());
// params.insert("currentPowerAcquisition", m_energyManager->currentPowerAcquisition());
// params.insert("currentPowerStorage", m_energyManager->currentPowerStorage());
// params.insert("totalConsumption", m_energyManager->totalConsumption());
// params.insert("totalProduction", m_energyManager->totalProduction());
// params.insert("totalAcquisition", m_energyManager->totalAcquisition());
// params.insert("totalReturn", m_energyManager->totalReturn());
// emit PowerBalanceChanged(params);
// });
// connect(m_energyManager->logs(), &EnergyLogs::powerBalanceEntryAdded, this, [=](EnergyLogs::SampleRate sampleRate, const PowerBalanceLogEntry &entry){
// QVariantMap params;
// params.insert("sampleRate", enumValueName(sampleRate));
// params.insert("powerBalanceLogEntry", pack(entry));
// emit PowerBalanceLogEntryAdded(params);
// });
// connect(m_energyManager->logs(), &EnergyLogs::thingPowerEntryAdded, this, [=](EnergyLogs::SampleRate sampleRate, const ThingPowerLogEntry &entry){
// QVariantMap params;
// params.insert("sampleRate", enumValueName(sampleRate));
// params.insert("thingPowerLogEntry", pack(entry));
// emit ThingPowerLogEntryAdded(params);
// });
}
QString EvDashJsonHandler::name() const
@ -167,70 +46,3 @@ QString EvDashJsonHandler::name() const
return "EvDash";
}
// JsonReply *EnergyJsonHandler::GetRootMeter(const QVariantMap &params)
// {
// Q_UNUSED(params)
// QVariantMap ret;
// if (m_energyManager->rootMeter()) {
// ret.insert("rootMeterThingId", m_energyManager->rootMeter()->id());
// }
// return createReply(ret);
// }
// JsonReply *EnergyJsonHandler::SetRootMeter(const QVariantMap &params)
// {
// QVariantMap returns;
// if (!params.contains("rootMeterThingId")) {
// returns.insert("energyError", enumValueName(EnergyManager::EnergyErrorMissingParameter));
// return createReply(returns);
// }
// EnergyManager::EnergyError status = m_energyManager->setRootMeter(params.value("rootMeterThingId").toUuid());
// returns.insert("energyError", enumValueName(status));
// return createReply(returns);
// }
// JsonReply *EnergyJsonHandler::GetPowerBalance(const QVariantMap &params)
// {
// Q_UNUSED(params)
// QVariantMap ret;
// ret.insert("currentPowerConsumption", m_energyManager->currentPowerConsumption());
// ret.insert("currentPowerProduction", m_energyManager->currentPowerProduction());
// ret.insert("currentPowerAcquisition", m_energyManager->currentPowerAcquisition());
// ret.insert("currentPowerStorage", m_energyManager->currentPowerStorage());
// ret.insert("totalConsumption", m_energyManager->totalConsumption());
// ret.insert("totalProduction", m_energyManager->totalProduction());
// ret.insert("totalAcquisition", m_energyManager->totalAcquisition());
// ret.insert("totalReturn", m_energyManager->totalReturn());
// return createReply(ret);
// }
// JsonReply *EnergyJsonHandler::GetPowerBalanceLogs(const QVariantMap &params)
// {
// EnergyLogs::SampleRate sampleRate = enumNameToValue<EnergyLogs::SampleRate>(params.value("sampleRate").toString());
// QDateTime from = params.contains("from") ? QDateTime::fromMSecsSinceEpoch(params.value("from").toLongLong() * 1000) : QDateTime();
// QDateTime to = params.contains("to") ? QDateTime::fromMSecsSinceEpoch(params.value("to").toLongLong() * 1000) : QDateTime();
// QVariantMap returns;
// returns.insert("powerBalanceLogEntries", pack(m_energyManager->logs()->powerBalanceLogs(sampleRate, from, to)));
// return createReply(returns);
// }
// JsonReply *EnergyJsonHandler::GetThingPowerLogs(const QVariantMap &params)
// {
// EnergyLogs::SampleRate sampleRate = enumNameToValue<EnergyLogs::SampleRate>(params.value("sampleRate").toString());
// QList<ThingId> thingIds;
// foreach (const QVariant &thingId, params.value("thingIds").toList()) {
// thingIds.append(thingId.toUuid());
// }
// QDateTime from = params.contains("from") ? QDateTime::fromMSecsSinceEpoch(params.value("from").toLongLong() * 1000) : QDateTime();
// QDateTime to = params.contains("to") ? QDateTime::fromMSecsSinceEpoch(params.value("to").toLongLong() * 1000) : QDateTime();
// QVariantMap returns;
// returns.insert("thingPowerLogEntries", pack(m_energyManager->logs()->thingPowerLogs(sampleRate, thingIds, from, to)));
// if (params.contains("includeCurrent") && params.value("includeCurrent").toBool()) {
// returns.insert("currentEntries", pack(m_energyManager->logs()->thingPowerLogs(EnergyLogs::SampleRate1Min, thingIds, QDateTime::currentDateTime().addSecs(-60))));
// }
// return createReply(returns);
// }

View File

@ -1,6 +1,10 @@
#include "evdashwebserverresource.h"
#include <QFileInfo>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QUuid>
#include <QLoggingCategory>
Q_DECLARE_LOGGING_CATEGORY(dcEvDashExperience)
@ -20,7 +24,12 @@ HttpReply *EvDashWebServerResource::processRequest(const HttpRequest &request)
{
qCDebug(dcEvDashExperience()) << "Process request" << request.url().toString();
// Verify methods
const QString path = request.url().path();
if (path == basePath() + QStringLiteral("/api/login"))
return handleLoginRequest(request);
// Verify methods for static content
if (request.method() != HttpRequest::Get) {
HttpReply *reply = HttpReply::createErrorReply(HttpReply::MethodNotAllowed);
reply->setHeader(HttpReply::AllowHeader, "GET");
@ -28,13 +37,14 @@ HttpReply *EvDashWebServerResource::processRequest(const HttpRequest &request)
}
// Redirect base url to index
if (request.url().path() == basePath() || request.url().path() == basePath() + "/") {
if (path == basePath() || path == basePath() + QStringLiteral("/")) {
qCDebug(dcEvDashExperience()) << "Base URL called, redirect to main page...";
return redirectToIndex();
}
// Check if this is a static file we can provide
QString fileName = request.url().path().remove(basePath());
QString fileName = path;
fileName.remove(basePath());
qCDebug(dcEvDashExperience()) << "Check filename" << fileName;
if (verifyStaticFile(fileName))
return WebServerResource::createFileReply(":/dashboard" + fileName);
@ -60,3 +70,92 @@ bool EvDashWebServerResource::verifyStaticFile(const QString &fileName)
return false;
}
HttpReply *EvDashWebServerResource::handleLoginRequest(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()) {
qCWarning(dcEvDashExperience()) << "Invalid login payload" << parseError.errorString();
QJsonObject errorPayload {
{QStringLiteral("success"), false},
{QStringLiteral("error"), QStringLiteral("invalidRequest")}
};
return HttpReply::createJsonReply(QJsonDocument(errorPayload), HttpReply::BadRequest);
}
const QJsonObject requestObject = requestDoc.object();
const QString username = requestObject.value(QStringLiteral("username")).toString();
const QString password = requestObject.value(QStringLiteral("password")).toString();
if (!verifyCredentials(username, password)) {
QJsonObject response {
{QStringLiteral("success"), false},
{QStringLiteral("error"), QStringLiteral("unauthorized")}
};
return HttpReply::createJsonReply(QJsonDocument(response), HttpReply::Unauthorized);
}
const QString token = issueToken(username);
const TokenInfo tokenInfo = m_activeTokens.value(token);
QJsonObject payload {
{QStringLiteral("success"), true},
{QStringLiteral("token"), token},
{QStringLiteral("expiresAt"), tokenInfo.expiresAt.toString(Qt::ISODateWithMs)}
};
return HttpReply::createJsonReply(QJsonDocument(payload));
}
bool EvDashWebServerResource::verifyCredentials(const QString &username, const QString &password) const
{
Q_UNUSED(username)
Q_UNUSED(password)
return true;
}
QString EvDashWebServerResource::issueToken(const QString &username)
{
purgeExpiredTokens();
const QString token = QUuid::createUuid().toString(QUuid::WithoutBraces);
TokenInfo info;
info.username = username;
info.expiresAt = QDateTime::currentDateTimeUtc().addSecs(s_tokenLifetimeSeconds);
m_activeTokens.insert(token, info);
return token;
}
bool EvDashWebServerResource::validateToken(const QString &token)
{
purgeExpiredTokens();
auto it = m_activeTokens.find(token);
if (it == m_activeTokens.end())
return false;
if (it->expiresAt < QDateTime::currentDateTimeUtc()) {
m_activeTokens.erase(it);
return false;
}
return true;
}
void EvDashWebServerResource::purgeExpiredTokens()
{
const QDateTime now = QDateTime::currentDateTimeUtc();
auto it = m_activeTokens.begin();
while (it != m_activeTokens.end()) {
if (it->expiresAt < now)
it = m_activeTokens.erase(it);
else
++it;
}
}

View File

@ -2,9 +2,14 @@
#define EVDASHWEBSERVERRESOURCE_H
#include <QObject>
#include <QDateTime>
#include <QHash>
#include <QString>
#include <webserver/webserverresource.h>
class QJsonObject;
class EvDashWebServerResource : public WebServerResource
{
Q_OBJECT
@ -15,10 +20,25 @@ public:
HttpReply *processRequest(const HttpRequest &request) override;
bool validateToken(const QString &token);
private:
struct TokenInfo {
QString username;
QDateTime expiresAt;
};
HttpReply *handleLoginRequest(const HttpRequest &request);
HttpReply *redirectToIndex();
bool verifyStaticFile(const QString &fileName);
bool verifyCredentials(const QString &username, const QString &password) const;
QString issueToken(const QString &username);
void purgeExpiredTokens();
static constexpr int s_tokenLifetimeSeconds = 3600;
QHash<QString, TokenInfo> m_activeTokens;
};
#endif // EVDASHWEBSERVERRESOURCE_H