🚧 WIP
This commit is contained in:
+71
-39
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user