Major update, devices view and components

The view for devices is functional now. It is possible to view, link and unlink devices and possible to gain access to authenticated endpoints with a key encrypted by a one time pin.
This commit is contained in:
Jesse Malotaux 2025-04-04 11:29:19 +02:00
parent b598a090bc
commit a01e026aa1
12 changed files with 1282 additions and 673 deletions

1583
fe/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"dev": "vite --profile",
"build": "vite build --emptyOutDir",
"preview": "vite preview",
"lint": "eslint . --fix",

View file

@ -1,14 +1,14 @@
@import './style/_macro';
@import './style/_mcrm-block';
@import './style/_panel';
@import "./style/_macro.css";
@import "./style/_mcrm-block.css";
@import "./style/_panel.css";
@import 'tailwindcss';
@import "tailwindcss";
@variant dark (&:where(.dark, .dark *));
@theme {
--font-sans: 'Roboto', sans-serif;
--font-mono: 'Fira Code', monospace;
--font-sans: "Roboto", sans-serif;
--font-mono: "Fira Code", monospace;
}
body {

View file

@ -1,31 +1,50 @@
<template>
<nav id="main-menu">
<button id="menu-toggle" :class="menuOpen ? 'open' : ''" @click="menuOpen = !menuOpen">
<img class="logo" src="@/assets/img/Macrame-Logo-gradient.svg" aria-hidden="true" />
<button
id="menu-toggle"
:class="menuOpen ? 'open' : ''"
@click="menuOpen = !menuOpen"
>
<img
class="logo p-1"
:class="{ 'opacity-0': menuOpen }"
src="@/assets/img/Macrame-Logo-gradient.svg"
aria-hidden="true"
/>
<IconX :class="{ 'opacity-0': !menuOpen }" />
</button>
<ul :class="menuOpen ? 'open' : ''">
<li>
<RouterLink @click="menuOpen = false" to="/"> <IconHome />Dashboard </RouterLink>
<RouterLink @click="menuOpen = false" to="/">
<IconHome />Dashboard
</RouterLink>
</li>
<li>
<RouterLink @click="menuOpen = false" to="/panels"> <IconLayoutGrid />Panels </RouterLink>
<RouterLink @click="menuOpen = false" to="/panels">
<IconLayoutGrid />Panels
</RouterLink>
</li>
<li>
<RouterLink @click="menuOpen = false" to="/macros"> <IconKeyboard />Macros </RouterLink>
<RouterLink @click="menuOpen = false" to="/macros">
<IconKeyboard />Macros
</RouterLink>
</li>
<li>
<RouterLink @click="menuOpen = false" to="/devices"> <IconDevices />Devices </RouterLink>
<RouterLink @click="menuOpen = false" to="/devices">
<IconDevices />Device
</RouterLink>
</li>
<li>
<RouterLink @click="menuOpen = false" to="/settings"> <IconSettings />Settings </RouterLink>
<RouterLink @click="menuOpen = false" to="/settings">
<IconSettings />Settings
</RouterLink>
</li>
</ul>
</nav>
</template>
<script setup>
import { RouterLink } from 'vue-router'
import { RouterLink } from "vue-router";
import {
IconDevices,
IconHome,
@ -33,10 +52,10 @@ import {
IconLayoutGrid,
IconSettings,
IconX,
} from '@tabler/icons-vue'
import { ref } from 'vue'
} from "@tabler/icons-vue";
import { ref } from "vue";
const menuOpen = ref(false)
const menuOpen = ref(false);
</script>
<style>

View file

@ -8,13 +8,19 @@
</AlertComp>
<div class="mcrm-block block__light grid gap-4">
<h4 class="text-lg flex gap-4 items-center"><IconServer />Server</h4>
<h4 class="text-lg flex gap-4 items-center justify-between">
<span class="flex gap-4"><IconServer />Server</span>
<ButtonComp variant="primary" @click="checkServerStatus()"><IconReload /></ButtonComp>
</h4>
<p>
Connected to: <strong>{{ server.host }}</strong>
</p>
<AlertComp v-if="server.status === 'unauthorized'" type="warning">Not authorized</AlertComp>
<!-- Alerts -->
<AlertComp v-if="server.status === 'authorized'" type="success">Authorized</AlertComp>
<AlertComp v-if="server.status === 'requested'" type="info">
<AlertComp v-if="server.status === 'unlinked'" type="warning">Not linked</AlertComp>
<AlertComp v-if="server.status === 'unauthorized'" type="info">
<div class="grid gap-2">
<strong>Access requested</strong>
<p>
@ -34,7 +40,6 @@
</template>
</div>
</AlertComp>
<ButtonComp
v-if="server.status === 'unauthorized'"
variant="primary"
@ -43,17 +48,29 @@
<IconKey />
Request access
</ButtonComp>
<ButtonComp variant="danger" v-if="server.status === 'authorized'">
<ButtonComp
variant="danger"
v-if="server.status === 'authorized'"
@click="disonnectFromServer()"
>
<IconPlugConnectedX />
Disconnect
</ButtonComp>
</div>
<DialogComp ref="linkPinDialog">
<template #content>
<div class="grid gap-4">
<h3>Enter server link pin:</h3>
<input class="input" type="number" v-model="server.linkPin" />
<ButtonComp variant="primary" @click="getDeviceKey()">Enter</ButtonComp>
<div class="grid gap-4 w-64">
<h3>Server link pin:</h3>
<form class="grid gap-4" @submit.prevent="decryptKey()">
<input
class="input"
id="input-pin"
type="text"
pattern="[0-9]{4}"
v-model="server.inputPin"
/>
<ButtonComp variant="primary">Enter</ButtonComp>
</form>
</div>
</template>
</DialogComp>
@ -61,13 +78,23 @@
</template>
<script setup>
// TODO
// - [Delete local key button]
// - if not local key
// - - if !checkAccess -> requestAccess -> put settings.json (go)
// - - if checkAccess -> pingLink -> check for device.tmp (go)
// - - if [devicePin] -> handshake -> save key local, close dialog, update server status
import { IconKey, IconPlugConnectedX, IconReload, IconServer } from '@tabler/icons-vue'
import AlertComp from '../base/AlertComp.vue'
import ButtonComp from '../base/ButtonComp.vue'
import { onMounted, reactive, ref } from 'vue'
import { onMounted, onUpdated, reactive, ref } from 'vue'
import { useDeviceStore } from '@/stores/device'
import { deviceType, deviceModel, deviceVendor } from '@basitcodeenv/vue3-device-detect'
import DialogComp from '../base/DialogComp.vue'
import { AuthCall, decryptAES } from '@/services/EncryptService'
import axios from 'axios'
import { appUrl } from '@/services/ApiService'
const device = useDeviceStore()
@ -76,41 +103,82 @@ const linkPinDialog = ref()
const server = reactive({
host: '',
status: false,
access: false,
link: false,
linkPin: '',
inputPin: '',
encryptedKey: '',
key: '',
})
onMounted(async () => {
server.host = window.location.host
device.$subscribe((mutation, state) => {
if (Object.keys(state.server).length) server.status = state.server.status
})
const status = await device.remoteCheckServerAccess()
server.status = status
})
onUpdated(() => {
if (!server.status) checkServerStatus()
})
async function checkServerStatus(request = true) {
const status = await device.remoteCheckServerAccess()
server.status = status
if (status === 'unlinked' || status === 'unauthorized') {
if (request) requestAccess()
return true
}
if (!device.key()) {
server.status = 'unauthorized'
return true
}
const handshake = await device.remoteHandshake(device.key())
if (handshake) server.key = device.key()
else {
device.removeDeviceKey()
server.status = 'unlinked'
if (request) requestAccess()
}
return true
}
function requestAccess() {
let deviceName = `${deviceVendor() ? deviceVendor() : 'Unknown'} ${deviceVendor() ? deviceModel() : deviceType()}`
device.remoteRequestServerAccess(deviceName, deviceType()).then((data) => {
if (data.data) pingLink()
if (data.data) (server.status = data.data), pingLink()
})
}
function pingLink() {
server.link = 'checking'
device.remotePingLink((link) => {
device.remotePingLink((encryptedKey) => {
server.link = true
server.encryptedKey = encryptedKey
linkPinDialog.value.toggleDialog(true)
console.log(link, 'opendialog')
})
}
function getDeviceKey() {
device.remoteHandshake(server.linkPin)
async function decryptKey() {
const decryptedKey = decryptAES(server.inputPin, server.encryptedKey)
const handshake = await device.remoteHandshake(decryptedKey)
if (handshake) {
device.setDeviceKey(decryptedKey)
server.key = decryptedKey
linkPinDialog.value.toggleDialog(false)
server.status = 'authorized'
}
}
function disonnectFromServer() {
axios.post(appUrl() + '/device/link/remove', AuthCall({ uuid: device.uuid() })).then((data) => {
if (data.data) checkServerStatus(false)
})
}
</script>
@ -122,4 +190,7 @@ function getDeviceKey() {
gap-4
content-start;
}
#input-pin {
}
</style>

View file

@ -7,7 +7,11 @@
</div>
</AlertComp>
<div class="mcrm-block block__light flex flex-wrap items-start">
<div class="mcrm-block block__light flex flex-wrap items-start gap-4">
<h4 class="w-full flex gap-4 items-center justify-between mb-4">
<span class="flex gap-4"> <IconDevices />Remote devices </span>
<ButtonComp variant="primary" @click="device.serverGetRemotes()"><IconReload /></ButtonComp>
</h4>
<!-- {{ Object.keys(remote.devices).length }} -->
<template v-if="Object.keys(remote.devices).length > 0">
<div
@ -28,32 +32,32 @@
<em>{{ id }}</em>
</div>
<template v-if="remoteDevice.key">
<AlertComp type="success">Linked</AlertComp>
<ButtonComp variant="danger"> <IconLinkOff />Unlink device </ButtonComp>
<AlertComp type="success">Authorized</AlertComp>
<ButtonComp variant="danger" @click="unlinkDevice(id)">
<IconLinkOff />Unlink device
</ButtonComp>
</template>
<template v-else>
<AlertComp type="warning">Not linked</AlertComp>
<AlertComp type="warning">Unauthorized</AlertComp>
<ButtonComp variant="primary" @click="startLink(id)">
<IconLink />Link device
</ButtonComp>
</template>
<template v-if="remote.pinlink.uuid == id">
<AlertComp type="info">One time pin: {{ remote.pinlink.pin }}</AlertComp>
</template>
</div>
</template>
<template v-else>
<div class="grid w-full gap-4">
<em class="text-slate-300">No remote devices</em>
<div class="flex justify-end">
<ButtonComp variant="primary" @click="device.serverGetRemotes()">
<IconReload />Check for access requests
</ButtonComp>
</div>
</div>
</template>
<DialogComp ref="pinDialog">
<template #content>
<div class="grid gap-4">
<h3>Pin code</h3>
<span class="text-4xl font-mono tracking-wide">{{ remote.pinlink }}</span>
<span class="text-4xl font-mono tracking-wide">{{ remote.pinlink.pin }}</span>
</div>
</template>
</DialogComp>
@ -62,14 +66,18 @@
</template>
<script setup>
// TODO
// - startLink -> responsePin also in device block
// - startLink -> poll removal of pin file, if removed close dialog, update device list
// - Make unlink work
import { onMounted, reactive, ref } from 'vue'
import AlertComp from '../base/AlertComp.vue'
import { useDeviceStore } from '@/stores/device'
import {
IconDevices,
IconDeviceDesktop,
IconDeviceMobile,
IconDevicesMinus,
IconDevicesPlus,
IconDeviceTablet,
IconDeviceUnknown,
IconLink,
@ -78,12 +86,14 @@ import {
} from '@tabler/icons-vue'
import ButtonComp from '../base/ButtonComp.vue'
import DialogComp from '../base/DialogComp.vue'
import axios from 'axios'
import { appUrl } from '@/services/ApiService'
const device = useDeviceStore()
const pinDialog = ref()
const remote = reactive({ devices: [], pinlink: '' })
const remote = reactive({ devices: [], pinlink: false })
onMounted(() => {
device.serverGetRemotes()
@ -96,8 +106,37 @@ onMounted(() => {
async function startLink(deviceUuid) {
const pin = await device.serverStartLink(deviceUuid)
remote.pinlink = pin
remote.pinlink = { uuid: deviceUuid, pin: pin }
pinDialog.value.toggleDialog(true)
pollLink()
setTimeout(() => {
resetPinLink()
}, 60000)
}
function pollLink() {
const pollInterval = setInterval(() => {
axios.post(appUrl() + '/device/link/poll', { uuid: remote.pinlink.uuid }).then((data) => {
if (!data.data) {
clearInterval(pollInterval)
resetPinLink()
device.serverGetRemotes()
}
})
}, 1000)
}
function resetPinLink() {
remote.pinlink = false
if (pinDialog.value) pinDialog.value.toggleDialog(false)
}
function unlinkDevice(id) {
axios.post(appUrl() + '/device/link/remove', { uuid: id }).then((data) => {
if (data.data) device.serverGetRemotes()
})
}
</script>

View file

@ -16,7 +16,8 @@ import { IconKeyboard } from '@tabler/icons-vue'
import ButtonComp from '../base/ButtonComp.vue'
import { onMounted, reactive } from 'vue'
import axios from 'axios'
import { appUrl } from '@/services/ApiService'
import { appUrl, isLocal } from '@/services/ApiService'
import { AuthCall } from '@/services/EncryptService'
const macros = reactive({
list: [],
@ -29,8 +30,9 @@ onMounted(() => {
})
function runMacro(macro) {
console.log(macro)
axios.post(appUrl() + '/macro/play', { macro: macro }).then((data) => {
const data = isLocal() ? { macro: macro } : AuthCall({ macro: macro })
axios.post(appUrl() + '/macro/play', data).then((data) => {
console.log(data)
})
}

View file

@ -1,15 +1,15 @@
// import './assets/jemx.scss'
import './assets/main.css'
import "@/assets/main.css";
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from './App.vue'
import router from './router'
import App from "@/App.vue";
import router from "@/router";
const app = createApp(App)
const app = createApp(App);
app.use(createPinia())
app.use(router)
app.use(createPinia());
app.use(router);
app.mount('#app')
app.mount("#app");

View file

@ -0,0 +1,51 @@
import { useDeviceStore } from '@/stores/device'
import { AES, enc, pad } from 'crypto-js'
export const encryptAES = (key, str) => {
key = keyPad(key)
let iv = enc.Utf8.parse(import.meta.env.VITE_MCRM__IV)
let encrypted = AES.encrypt(str, key, {
iv: iv,
padding: pad.Pkcs7,
})
return encrypted.toString()
}
export const decryptAES = (key, str) => {
key = keyPad(key)
let iv = enc.Utf8.parse(import.meta.env.VITE_MCRM__IV)
let encrypted = AES.decrypt(str.toString(), key, {
iv: iv,
padding: pad.Pkcs7,
})
return encrypted.toString(enc.Utf8)
}
export const AuthCall = (data) => {
const device = useDeviceStore()
return {
uuid: device.uuid(),
d: encryptAES(device.key(), JSON.stringify(data)),
}
}
function keyPad(key) {
let returnKey = key
if (key.length == 4) {
returnKey = key + import.meta.env.VITE_MCRM__SALT
}
return enc.Utf8.parse(returnKey)
}
export const getDateStr = () => {
const date = new Date()
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}${month}${day}`
}

View file

@ -3,11 +3,13 @@ import { defineStore } from 'pinia'
import axios from 'axios'
import { appUrl, encrypt } from '@/services/ApiService'
import { v4 as uuidv4 } from 'uuid'
import { encryptAES, getDateStr } from '@/services/EncryptService'
export const useDeviceStore = defineStore('device', () => {
// Properties - State values
const current = ref({
uuid: false,
key: false,
})
const remote = ref([])
@ -31,6 +33,23 @@ export const useDeviceStore = defineStore('device', () => {
return uuid
}
const key = () => {
if (!current.value.key && localStorage.getItem('deviceKey')) {
current.value.key = localStorage.getItem('deviceKey')
}
return current.value.key
}
const setDeviceKey = (key) => {
current.value.key = key
localStorage.setItem('deviceKey', key)
}
const removeDeviceKey = () => {
current.value.key = false
localStorage.removeItem('deviceKey')
}
// Server application
const serverGetRemotes = async (remoteUuid) => {
axios.post(appUrl() + '/device/list', { uuid: remoteUuid }).then((data) => {
@ -71,13 +90,14 @@ export const useDeviceStore = defineStore('device', () => {
}, 1000)
}
const remoteHandshake = async (pin) => {
// send encrypt(uuid + pin)
// then decrypt data with pin = key
axios.post(appUrl() + '/device/handshake', { shake: pin }).then((data) => {
console.log(data)
const remoteHandshake = async (key) => {
const handshake = await axios.post(appUrl() + '/device/handshake', {
uuid: uuid(),
shake: encryptAES(key, getDateStr()),
})
console.log(handshake)
return handshake.data
}
return {
@ -85,6 +105,9 @@ export const useDeviceStore = defineStore('device', () => {
server,
uuid,
setDeviceId,
key,
setDeviceKey,
removeDeviceKey,
serverGetRemotes,
serverStartLink,
remoteCheckServerAccess,

View file

@ -3,9 +3,9 @@
<h1 class="panel__title">
Devices <span class="text-sm">{{ isLocal() ? 'remote' : 'servers' }}</span>
</h1>
<div class="panel__content grid sm:grid-cols-2 gap-8">
<ServerView />
<RemoteView />
<div class="panel__content grid gap-8">
<ServerView v-if="isLocal()" />
<RemoteView v-else />
</div>
</div>
</template>

View file

@ -7,16 +7,25 @@ import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
server: {
host: 'localhost',
port: 5173,
watch: {
usePolling: true,
},
},
plugins: [vue(), vueDevTools(), tailwindcss()],
envDir: '../',
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
base: '/',
publicDir: '../public',
// publicDir: "../public",
build: {
outDir: '../public',
sourcemap: true,
sourcemap: false,
minify: false,
},
})