This commit is contained in:
Jonas Bengtsson
2026-09-05 14:22:29 +02:00
parent f9d10364f8
commit 2117f96ae7
20 changed files with 1721 additions and 112 deletions
+71 -39
View File
@@ -1,36 +1,76 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { Cpu, KeyRound, LayoutDashboard, MessageSquareText, Moon, Sun } from 'lucide-vue-next'
import { computed, onMounted, ref } from 'vue'
import {
Cpu,
KeyRound,
LayoutDashboard,
LogOut,
MessageSquareText,
Moon,
Sun,
UserRound,
} from 'lucide-vue-next'
import { api } from './api'
import { router } from './router'
import { applyTheme, initialTheme, type Theme } from './theme'
import { submitToken, ui } from './store'
import { canEdit, clearSession, ui } from './store'
const token = ref('')
const theme = ref<Theme>(initialTheme())
function onSubmit() {
submitToken(token.value)
token.value = ''
}
// the login screen renders full-page, without the sidebar
const isLogin = computed(() => router.currentRoute.value.path === '/login')
function toggleTheme() {
theme.value = theme.value === 'dark' ? 'light' : 'dark'
applyTheme(theme.value)
}
// Apply on mount (theme.ts also runs before the app mounts, see main.ts)
// so the sidebar ghost button label matches the active theme.
async function logout() {
try {
await api('/api/auth/logout', { method: 'POST' })
} catch {
// already invalid; the local session is cleared regardless
}
clearSession()
router.push('/login')
}
onMounted(() => applyTheme(theme.value))
</script>
<template>
<div class="shell">
<div v-if="isLogin" class="shell">
<main style="max-width: none">
<router-view />
</main>
</div>
<div v-else class="shell">
<aside class="sidebar">
<div class="brand">.:: vivab ::.</div>
<div class="nav-section">Data</div>
<router-link to="/"><LayoutDashboard :size="17" /> <span>Dashboard</span></router-link>
<router-link to="/devices"><Cpu :size="17" /> <span>Devices</span></router-link>
<router-link to="/tokens"><KeyRound :size="17" /> <span>Tokens</span></router-link>
<router-link to="/messages"><MessageSquareText :size="17" /> <span>Messages</span></router-link>
<router-link to="/messages">
<MessageSquareText :size="17" /> <span>Messages</span>
</router-link>
<div v-if="canEdit()" class="nav-section">Access</div>
<router-link v-if="canEdit()" to="/tokens">
<KeyRound :size="17" /> <span>Tokens</span>
</router-link>
<router-link v-if="canEdit()" to="/users">
<UserRound :size="17" /> <span>Users</span>
</router-link>
<div class="bottom">
<div v-if="ui.session" class="session-box">
<div class="muted" style="padding: 0 0.25rem 0.5rem">
{{ ui.session.username }} · {{ ui.session.role }}
</div>
<button class="ghost" @click="logout">
<LogOut :size="16" />
<span>Log out</span>
</button>
</div>
<button class="ghost" @click="toggleTheme">
<Sun v-if="theme === 'dark'" :size="16" />
<Moon v-else :size="16" />
@@ -42,31 +82,23 @@ onMounted(() => applyTheme(theme.value))
<main>
<router-view />
</main>
</div>
<div v-if="ui.toasts.length" class="toasts">
<div
v-for="t in ui.toasts"
:key="t.id"
class="toast"
:class="t.kind"
@click="ui.toasts.splice(ui.toasts.indexOf(t), 1)"
>
{{ t.text }}
</div>
</div>
<div v-if="ui.showTokenPrompt" class="modal-backdrop">
<form class="modal" @submit.prevent="onSubmit">
<h1>App token</h1>
<p class="muted">
Paste an app token (X-App-Token). It is stored in this browser and sent with
every request. A real login flow will replace this later.
</p>
<input v-model="token" type="password" placeholder="app token" autofocus />
<div class="row" style="margin-top: 1rem">
<button class="primary" type="submit">Save</button>
</div>
</form>
<div v-if="ui.toasts.length" class="toasts">
<div
v-for="t in ui.toasts"
:key="t.id"
class="toast"
:class="t.kind"
@click="ui.toasts.splice(ui.toasts.indexOf(t), 1)"
>
{{ t.text }}
</div>
</div>
</template>
</template>
<style scoped>
.session-box {
margin-bottom: 0.5rem;
}
</style>
+13 -11
View File
@@ -50,16 +50,17 @@ export class ApiError extends Error {
}
}
// The auth token is pasted into the app by the admin (real login comes
// later); kept in localStorage and sent as X-App-Token on every request.
const TOKEN_KEY = 'nodeledge-ui-token'
// The session (bearer token + role) lives in localStorage; read directly
// here to avoid a circular import with store.ts.
const SESSION_KEY = 'nodeledge-ui-session'
export function getToken(): string {
return localStorage.getItem(TOKEN_KEY) ?? ''
}
export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token)
function sessionToken(): string {
try {
const raw = localStorage.getItem(SESSION_KEY)
return raw ? (JSON.parse(raw) as { token: string }).token : ''
} catch {
return ''
}
}
type Listener = () => void
@@ -71,17 +72,18 @@ export function onUnauthorized(listener: Listener): () => void {
}
export async function api<T = unknown>(path: string, init?: RequestInit): Promise<T> {
const token = sessionToken()
const res = await fetch(path, {
...init,
headers: {
'X-App-Token': getToken(),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(init?.body ? { 'Content-Type': 'application/json' } : {}),
...init?.headers,
},
})
if (res.status === 401) {
for (const listener of unauthorizedListeners) listener()
throw new ApiError(401, 'invalid or missing app token')
throw new ApiError(401, 'not logged in')
}
if (!res.ok) {
const body: unknown = await res.json().catch(() => null)
+21
View File
@@ -1,19 +1,40 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import { loadSession, ui } from './store'
import Dashboard from './views/Dashboard.vue'
import Devices from './views/Devices.vue'
import DeviceDetail from './views/DeviceDetail.vue'
import Tokens from './views/Tokens.vue'
import Users from './views/Users.vue'
import Messages from './views/Messages.vue'
import Login from './views/Login.vue'
// Hash routing: SPA routes can never collide with the API paths that the
// nginx /api proxy serves.
export const router = createRouter({
history: createWebHashHistory(),
routes: [
{ path: '/login', component: Login },
{ path: '/', component: Dashboard },
{ path: '/devices', component: Devices },
{ path: '/devices/:imei', component: DeviceDetail, props: true },
{ path: '/tokens', component: Tokens },
{ path: '/users', component: Users },
{ path: '/messages', component: Messages },
],
})
// Route guard: everything except /login requires a session. An expired or
// revoked session is caught by the api() wrapper (401 -> clearSession),
// and the next navigation bounces to /login. Role-gated routes redirect
// unauthorized roles back to the dashboard (the API enforces 403 anyway;
// this keeps the URL honest).
router.beforeEach((to) => {
loadSession()
if (to.path !== '/login' && !ui.session) return '/login'
if (ui.session && to.path.startsWith('/users') && ui.session.role === 'user') {
return '/'
}
if (ui.session && to.path.startsWith('/tokens') && ui.session.role === 'user') {
return '/'
}
})
+46 -12
View File
@@ -1,7 +1,10 @@
// Shared UI state: toasts + the token prompt (login UI comes later).
// Shared UI state: toasts + the login session. Sessions are opaque
// bearer tokens issued by POST /auth/login (users table, roles: admin /
// superuser / user); the token lives in localStorage under this key and
// is sent as Authorization: Bearer by the api() wrapper.
import { reactive } from 'vue'
import { ApiError, onUnauthorized, setToken } from './api'
import { ApiError, onUnauthorized } from './api'
export interface Toast {
id: number
@@ -9,9 +12,17 @@ export interface Toast {
text: string
}
export interface Session {
token: string
username: string
role: 'admin' | 'superuser' | 'user'
}
const SESSION_KEY = 'nodeledge-ui-session'
const state = reactive({
toasts: [] as Toast[],
showTokenPrompt: false,
session: null as Session | null,
nextToastId: 1,
})
@@ -32,13 +43,36 @@ export function showError(error: unknown): void {
export const ui = state
// Any 401 from any screen re-opens the token prompt.
onUnauthorized(() => {
state.showTokenPrompt = true
})
export function setSession(session: Session): void {
localStorage.setItem(SESSION_KEY, JSON.stringify(session))
state.session = session
}
export function submitToken(token: string): void {
setToken(token)
state.showTokenPrompt = false
toast('success', 'token saved')
}
export function loadSession(): void {
try {
const raw = localStorage.getItem(SESSION_KEY)
state.session = raw ? (JSON.parse(raw) as Session) : null
} catch {
state.session = null
}
}
export function clearSession(): void {
localStorage.removeItem(SESSION_KEY)
state.session = null
}
export function canEdit(): boolean {
// superuser and admin may write; app-token sessions are not a thing in
// the UI anymore (login is mandatory), so the role drives everything.
return state.session !== null && state.session.role !== 'user'
}
export function isAdmin(): boolean {
return state.session?.role === 'admin'
}
// Any 401 from any screen invalidates the session (expired/deleted user).
onUnauthorized(() => {
clearSession()
})
+20
View File
@@ -80,6 +80,22 @@ body {
letter-spacing: 0.01em;
}
/* non-clickable section labels (Data / Access) */
.sidebar .nav-section {
color: #64748b;
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.09em;
padding: 1rem 0.75rem 0.3rem;
border-top: 1px solid rgb(255 255 255 / 8%);
}
.sidebar .nav-section:first-of-type {
padding-top: 0.25rem;
border-top: none;
}
.sidebar a {
display: flex;
align-items: center;
@@ -505,6 +521,10 @@ pre.json {
padding: 0.5rem 0.75rem;
}
.sidebar .nav-section {
display: none;
}
.sidebar a span {
display: none;
}
+87 -4
View File
@@ -4,7 +4,7 @@ import { Plus, Trash2 } from 'lucide-vue-next'
import { api } from '../api'
import type { AttributeHistory, AttributeVersion, Device, ResolvedAttributes } from '../api'
import { formatTime, localInputToUtcIso } from '../format'
import { showError, toast } from '../store'
import { canEdit, showError, toast } from '../store'
const props = defineProps<{ imei: string }>()
@@ -151,6 +151,54 @@ async function deleteLatest(key: string) {
}
}
// --- backfill rename of stored messages ---------------------------------
const renName = ref('')
const renFrom = ref('')
const renTo = ref('')
const renPreview = ref<number | null>(null)
async function previewRename() {
const body: Record<string, unknown> = { device: renName.value.trim(), dry_run: true }
const f = localInputToUtcIso(renFrom.value)
const t = localInputToUtcIso(renTo.value)
if (f) body.from = f
if (t) body.to = t
try {
const res = await api<{ updated: number }>(`/api/devices/${props.imei}/rename`, {
method: 'POST',
body: JSON.stringify(body),
})
renPreview.value = res.updated
} catch (e) {
showError(e)
}
}
async function applyRename() {
if (
!confirm(
`Rename ALL matching stored messages to "${renName.value.trim()}"? ` +
`This rewrites the device column and the payload's device key — there is no undo.`,
)
)
return
const body: Record<string, unknown> = { device: renName.value.trim() }
const f = localInputToUtcIso(renFrom.value)
const t = localInputToUtcIso(renTo.value)
if (f) body.from = f
if (t) body.to = t
try {
const res = await api<{ updated: number }>(`/api/devices/${props.imei}/rename`, {
method: 'POST',
body: JSON.stringify(body),
})
toast('success', `${res.updated} message(s) renamed`)
renPreview.value = null
} catch (e) {
showError(e)
}
}
onMounted(async () => {
await loadDevice()
await loadAttributes()
@@ -164,7 +212,7 @@ onMounted(async () => {
</h1>
<h2>Settings</h2>
<form class="card" @submit.prevent="saveSettings">
<form v-if="canEdit()" class="card" @submit.prevent="saveSettings">
<div class="row" style="margin-bottom: 0.75rem">
<label style="width: 8rem">Device name</label>
<input v-model="name" class="grow" />
@@ -204,7 +252,7 @@ onMounted(async () => {
</form>
<h2>Attribute timeline (time-varying values)</h2>
<form class="card" @submit.prevent="appendAttribute">
<form v-if="canEdit()" class="card" @submit.prevent="appendAttribute">
<div class="row" style="margin-bottom: 0.5rem">
<input v-model="attrKey" class="mono" placeholder="key (e.g. temperature_offset)" style="max-width: 16rem" />
<input v-model="attrValue" class="mono grow" placeholder="value (number, text or JSON)" />
@@ -243,7 +291,7 @@ onMounted(async () => {
<span class="when" style="margin-left: 0.5rem">{{ formatTime(v.effective_from) }}</span>
</span>
<button
v-if="isLatest(key, v)"
v-if="canEdit() && isLatest(key, v)"
class="danger"
style="padding: 0.25rem 0.6rem"
@click="deleteLatest(key)"
@@ -256,4 +304,39 @@ onMounted(async () => {
</li>
</ul>
</div>
<h2 v-if="canEdit()">Rename stored messages</h2>
<form v-if="canEdit()" class="card" @submit.prevent="previewRename">
<div class="row" style="margin-bottom: 0.5rem">
<input v-model="renName" placeholder="New device name" class="grow" />
</div>
<div class="row" style="margin-bottom: 0.5rem">
<label>from</label>
<input v-model="renFrom" type="datetime-local" style="max-width: 14rem" />
<span class="muted">empty = all history</span>
<label>to</label>
<input v-model="renTo" type="datetime-local" style="max-width: 14rem" />
<span class="muted">empty = up to now</span>
</div>
<div class="row">
<button type="submit">Preview</button>
<span v-if="renPreview !== null" class="badge" :class="{ ok: renPreview > 0 }">
{{ renPreview }} message(s) match
</span>
<span class="grow"></span>
<button
class="danger"
type="button"
:disabled="renPreview === null || renPreview === 0"
@click="applyRename"
>
Apply rename (no undo)
</button>
</div>
<p class="muted">
Rewrites the device name on already-stored messages for this IMEI (both the
column and the payload key) — use it when a device was misnamed at ingest.
The IMEI is never touched. To rename FUTURE messages, just edit the name
in the settings form above.
</p>
</form>
</template>
+3 -3
View File
@@ -3,7 +3,7 @@ import { onMounted, ref } from 'vue'
import { Cpu, Plus, Trash2 } from 'lucide-vue-next'
import { api } from '../api'
import type { Device } from '../api'
import { showError, toast } from '../store'
import { canEdit, showError, toast } from '../store'
const devices = ref<Device[]>([])
const loaded = ref(false)
@@ -56,7 +56,7 @@ async function removeDevice(d: Device) {
<template>
<h1>Devices</h1>
<form class="card row" @submit.prevent="addDevice">
<form v-if="canEdit()" class="card row" @submit.prevent="addDevice">
<input v-model="newImei" class="grow mono" placeholder="IMEI (15 digits)" style="max-width: 14rem" />
<input v-model="newName" class="grow" placeholder="Device name" />
<button class="primary" type="submit">
@@ -81,7 +81,7 @@ async function removeDevice(d: Device) {
<router-link :to="`/devices/${d.imei}`">{{ d.device }}</router-link>
</td>
<td style="text-align: right">
<button class="danger" @click="removeDevice(d)">
<button v-if="canEdit()" class="danger" @click="removeDevice(d)">
<Trash2 :size="15" />
Delete
</button>
+64
View File
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { ref } from 'vue'
import { LogIn } from 'lucide-vue-next'
import { api } from '../api'
import { setSession, showError, toast } from '../store'
const username = ref('')
const password = ref('')
async function submit() {
try {
const res = await api<{ token: string; username: string; role: 'admin' | 'superuser' | 'user' }>(
'/api/auth/login',
{
method: 'POST',
body: JSON.stringify({ username: username.value, password: password.value }),
},
)
setSession({ token: res.token, username: res.username, role: res.role })
password.value = ''
toast('success', `logged in as ${res.username} (${res.role})`)
window.location.hash = '#/'
} catch (e) {
showError(e)
}
}
</script>
<template>
<div class="login-wrap">
<form class="card login-card" @submit.prevent="submit">
<div class="row" style="justify-content: center; margin-bottom: 0.75rem">
<LogIn :size="26" />
</div>
<h1 style="text-align: center; margin-bottom: 1rem">Sign in</h1>
<div class="row" style="margin-bottom: 0.6rem">
<label style="width: 5rem">Username</label>
<input v-model="username" autofocus autocomplete="username" />
</div>
<div class="row" style="margin-bottom: 1rem">
<label style="width: 5rem">Password</label>
<input v-model="password" type="password" autocomplete="current-password" />
</div>
<button class="primary" type="submit" style="width: 100%; justify-content: center">
Log in
</button>
<p class="muted" style="text-align: center; margin-bottom: 0">
Sessions expire after 30 days; the app token header is for machine clients.
</p>
</form>
</div>
</template>
<style scoped>
.login-wrap {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-card {
width: min(90vw, 22rem);
}
</style>
+3 -3
View File
@@ -2,7 +2,7 @@
import { onMounted, ref } from 'vue'
import { KeyRound, Plus, Trash2 } from 'lucide-vue-next'
import { api } from '../api'
import { showError, toast } from '../store'
import { canEdit, showError, toast } from '../store'
const names = ref<string[]>([])
const loaded = ref(false)
@@ -63,7 +63,7 @@ async function removeToken(name: string) {
rotates its secret; the change takes effect immediately.
</p>
<form class="card row" @submit.prevent="saveToken">
<form v-if="canEdit()" class="card row" @submit.prevent="saveToken">
<input v-model="newName" placeholder="app name" style="max-width: 14rem" />
<input v-model="newSecret" placeholder="secret" type="password" class="grow" />
<button class="primary" type="submit">
@@ -84,7 +84,7 @@ async function removeToken(name: string) {
<tr v-for="n in names" :key="n">
<td class="mono">{{ n }}</td>
<td style="text-align: right">
<button class="danger" @click="removeToken(n)">
<button v-if="canEdit()" class="danger" @click="removeToken(n)">
<Trash2 :size="15" />
Delete
</button>
+124
View File
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { Plus, Trash2, UserRound } from 'lucide-vue-next'
import { api } from '../api'
import { canEdit, isAdmin, showError, toast, ui } from '../store'
const users = ref<{ username: string; role: string }[]>([])
const loaded = ref(false)
const newName = ref('')
const newPassword = ref('')
// superusers may only create 'user' accounts (server-enforced); admins any.
const newRole = ref('user')
async function load() {
try {
users.value = await api<{ username: string; role: string }[]>('/api/users')
} catch (e) {
showError(e)
} finally {
loaded.value = true
}
}
onMounted(load)
async function saveUser() {
if (!newName.value.trim() || !newPassword.value) {
toast('error', 'username and password are required')
return
}
try {
await api(`/api/users/${encodeURIComponent(newName.value.trim())}`, {
method: 'PUT',
body: JSON.stringify({ password: newPassword.value, role: newRole.value }),
})
newName.value = ''
newPassword.value = ''
await load()
toast('success', 'user saved')
} catch (e) {
showError(e)
}
}
function mayDelete(target: { username: string; role: string }): boolean {
if (!canEdit()) return false
if (isAdmin()) return target.username !== ui.session?.username
// superuser: only accounts with role 'user'
return target.role === 'user'
}
async function removeUser(u: { username: string; role: string }) {
const last =
u.role === 'admin' && users.value.filter((x) => x.role === 'admin').length <= 1
const warning = last
? `This is the LAST admin. Delete ${u.username} anyway?`
: `Delete user ${u.username}? Their sessions are revoked too.`
if (!confirm(warning)) return
try {
await api(`/api/users/${encodeURIComponent(u.username)}`, { method: 'DELETE' })
await load()
} catch (e) {
showError(e)
}
}
</script>
<template>
<h1>Users</h1>
<p class="muted">
Passwords are stored as scrypt hashes and cannot be read back. Re-adding a user
resets their password (and role). Roles:
<strong>admin</strong> manages everything including users,
<strong>superuser</strong> configures devices/tokens and manages day-to-day
<strong>user</strong> accounts, <strong>user</strong> is a read-only viewer.
</p>
<form v-if="canEdit()" class="card row" @submit.prevent="saveUser">
<input v-model="newName" placeholder="username" style="max-width: 12rem" />
<input v-model="newPassword" placeholder="password" type="password" class="grow" />
<select v-if="isAdmin()" v-model="newRole" style="max-width: 9rem">
<option value="user">user</option>
<option value="superuser">superuser</option>
<option value="admin">admin</option>
</select>
<button class="primary" type="submit">
<Plus :size="15" />
Add / update
</button>
</form>
<div class="card">
<table v-if="users.length">
<thead>
<tr>
<th>Username</th>
<th>Role</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="u in users" :key="u.username">
<td class="mono">{{ u.username }}</td>
<td>
<span class="badge" :class="{ ok: u.role === 'admin' }">{{ u.role }}</span>
</td>
<td style="text-align: right">
<button v-if="mayDelete(u)" class="danger" @click="removeUser(u)">
<Trash2 :size="15" />
Delete
</button>
</td>
</tr>
</tbody>
</table>
<div v-else-if="loaded" class="empty">
<UserRound :size="28" />
<span>No users yet.</span>
</div>
<template v-else>
<div class="skeleton line" />
</template>
</div>
</template>