diff --git a/docker-compose.yml b/docker-compose.yml
index 05ce462..428244b 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -51,3 +51,17 @@ services:
depends_on:
- backend
restart: unless-stopped
+
+ sysadmin_panel:
+ image: node:20-alpine
+ working_dir: /app
+ volumes:
+ - ./sysadmin_panel:/app
+ ports:
+ - "5175:5175"
+ command: sh -c "npm install && npm run dev -- --host 0.0.0.0"
+ env_file:
+ - ./sysadmin_panel/.env
+ depends_on:
+ - cloud_backend
+ restart: unless-stopped
diff --git a/sysadmin_panel/index.html b/sysadmin_panel/index.html
new file mode 100644
index 0000000..c6ea24d
--- /dev/null
+++ b/sysadmin_panel/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ POS Sysadmin
+
+
+
+
+
+
diff --git a/sysadmin_panel/package.json b/sysadmin_panel/package.json
new file mode 100644
index 0000000..632e403
--- /dev/null
+++ b/sysadmin_panel/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "sysadmin-panel",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "axios": "^1.7.9",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-hot-toast": "^2.4.1",
+ "react-router-dom": "^6.28.0",
+ "zustand": "^5.0.2"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-react": "^4.3.4",
+ "autoprefixer": "^10.4.20",
+ "postcss": "^8.4.49",
+ "tailwindcss": "^3.4.16",
+ "vite": "^6.0.5"
+ }
+}
diff --git a/sysadmin_panel/postcss.config.js b/sysadmin_panel/postcss.config.js
new file mode 100644
index 0000000..2e7af2b
--- /dev/null
+++ b/sysadmin_panel/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
diff --git a/sysadmin_panel/src/App.jsx b/sysadmin_panel/src/App.jsx
new file mode 100644
index 0000000..e055b9d
--- /dev/null
+++ b/sysadmin_panel/src/App.jsx
@@ -0,0 +1,81 @@
+import { Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom'
+import useAuthStore from './store/authStore'
+import LoginPage from './pages/LoginPage'
+import SitesPage from './pages/SitesPage'
+import SiteDetailPage from './pages/SiteDetailPage'
+import RegisterSitePage from './pages/RegisterSitePage'
+
+function Layout({ children }) {
+ const { logout } = useAuthStore()
+ const navigate = useNavigate()
+ const location = useLocation()
+
+ function handleLogout() {
+ logout()
+ navigate('/login', { replace: true })
+ }
+
+ return (
+
+
+
+
+ {children}
+
+
+ )
+}
+
+function RequireAuth({ children }) {
+ const { token } = useAuthStore()
+ if (!token) return
+ return children
+}
+
+export default function App() {
+ const { token } = useAuthStore()
+
+ return (
+
+ : } />
+
+ } />
+
+ } />
+
+ } />
+ } />
+
+ )
+}
diff --git a/sysadmin_panel/src/api/client.js b/sysadmin_panel/src/api/client.js
new file mode 100644
index 0000000..c577939
--- /dev/null
+++ b/sysadmin_panel/src/api/client.js
@@ -0,0 +1,24 @@
+import axios from 'axios'
+
+const BASE_URL = import.meta.env.VITE_CLOUD_URL || 'http://localhost:8001'
+
+const client = axios.create({ baseURL: BASE_URL })
+
+client.interceptors.request.use(config => {
+ const token = localStorage.getItem('sysadmin_token')
+ if (token) config.headers.Authorization = `Bearer ${token}`
+ return config
+})
+
+client.interceptors.response.use(
+ res => res,
+ err => {
+ if (err.response?.status === 401) {
+ localStorage.removeItem('sysadmin_token')
+ window.location.href = '/login'
+ }
+ return Promise.reject(err)
+ }
+)
+
+export default client
diff --git a/sysadmin_panel/src/components/ConfirmModal.jsx b/sysadmin_panel/src/components/ConfirmModal.jsx
new file mode 100644
index 0000000..001ebbf
--- /dev/null
+++ b/sysadmin_panel/src/components/ConfirmModal.jsx
@@ -0,0 +1,29 @@
+export default function ConfirmModal({ title, message, confirmLabel = 'Confirm', danger = false, onConfirm, onCancel, children }) {
+ return (
+
+
+
{title}
+ {message &&
{message}
}
+ {children}
+
+
+
+
+
+
+ )
+}
diff --git a/sysadmin_panel/src/components/LicenseStatus.jsx b/sysadmin_panel/src/components/LicenseStatus.jsx
new file mode 100644
index 0000000..47ed959
--- /dev/null
+++ b/sysadmin_panel/src/components/LicenseStatus.jsx
@@ -0,0 +1,17 @@
+export default function LicenseStatus({ site }) {
+ const now = new Date()
+ const expires = new Date(site.license_expires_at)
+ const lastSeen = site.last_seen_at ? new Date(site.last_seen_at) : null
+ const hoursAgo = lastSeen ? (now - lastSeen) / 1000 / 3600 : null
+
+ if (site.is_locked) {
+ return Locked
+ }
+ if (expires < now) {
+ return Expired
+ }
+ if (hoursAgo === null || hoursAgo > 12) {
+ return No Heartbeat
+ }
+ return Active
+}
diff --git a/sysadmin_panel/src/components/SiteCard.jsx b/sysadmin_panel/src/components/SiteCard.jsx
new file mode 100644
index 0000000..603411e
--- /dev/null
+++ b/sysadmin_panel/src/components/SiteCard.jsx
@@ -0,0 +1,64 @@
+import { useNavigate } from 'react-router-dom'
+import LicenseStatus from './LicenseStatus'
+
+function fmtDate(dt) {
+ if (!dt) return '—'
+ return new Date(dt).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
+}
+
+function fmtAgo(dt) {
+ if (!dt) return 'Never'
+ const diff = (Date.now() - new Date(dt)) / 1000
+ if (diff < 60) return 'Just now'
+ if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
+ return `${Math.floor(diff / 86400)}d ago`
+}
+
+export default function SiteCard({ site }) {
+ const navigate = useNavigate()
+ const expires = new Date(site.license_expires_at)
+ const isExpired = expires < new Date()
+ const isLocked = site.is_locked
+
+ const borderColor = isLocked || isExpired
+ ? 'border-red-800/60 hover:border-red-700'
+ : site.last_seen_at && (Date.now() - new Date(site.last_seen_at)) / 3600000 < 12
+ ? 'border-emerald-800/40 hover:border-emerald-600'
+ : 'border-yellow-800/40 hover:border-yellow-600'
+
+ return (
+ navigate(`/sites/${site.site_id}`)}
+ className={`bg-gray-900 border ${borderColor} rounded-xl p-4 cursor-pointer transition-all hover:bg-gray-800`}
+ >
+
+
+
{site.name}
+
{site.owner_name}
+
+
+
+
+
+
+
Expires
+
{fmtDate(site.license_expires_at)}
+
+
+
Last seen
+
{fmtAgo(site.last_seen_at)}
+
+
+ {site.site_id}
+
+
+
+ {isLocked && site.lock_reason && (
+
+ Locked: {site.lock_reason}
+
+ )}
+
+ )
+}
diff --git a/sysadmin_panel/src/index.css b/sysadmin_panel/src/index.css
new file mode 100644
index 0000000..087b4b5
--- /dev/null
+++ b/sysadmin_panel/src/index.css
@@ -0,0 +1,8 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+body {
+ background-color: #030712;
+ color: #f9fafb;
+}
diff --git a/sysadmin_panel/src/main.jsx b/sysadmin_panel/src/main.jsx
new file mode 100644
index 0000000..8a1f648
--- /dev/null
+++ b/sysadmin_panel/src/main.jsx
@@ -0,0 +1,25 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import { BrowserRouter } from 'react-router-dom'
+import { Toaster } from 'react-hot-toast'
+import App from './App'
+import './index.css'
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+
+
+
+
+)
diff --git a/sysadmin_panel/src/pages/LoginPage.jsx b/sysadmin_panel/src/pages/LoginPage.jsx
new file mode 100644
index 0000000..114fbf7
--- /dev/null
+++ b/sysadmin_panel/src/pages/LoginPage.jsx
@@ -0,0 +1,89 @@
+import { useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import useAuthStore from '../store/authStore'
+import client from '../api/client'
+
+export default function LoginPage() {
+ const [username, setUsername] = useState('')
+ const [password, setPassword] = useState('')
+ const [error, setError] = useState('')
+ const [loading, setLoading] = useState(false)
+ const { login } = useAuthStore()
+ const navigate = useNavigate()
+
+ async function handleSubmit(e) {
+ e.preventDefault()
+ if (!username.trim() || !password) return
+ setError('')
+ setLoading(true)
+ try {
+ const { data } = await client.post('/api/auth/login', {
+ username: username.trim(),
+ password,
+ })
+ login(data.access_token)
+ navigate('/sites', { replace: true })
+ } catch (err) {
+ setError(err.response?.data?.detail || 'Invalid credentials')
+ setPassword('')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+
+
+
+
POS Sysadmin
+
Cloud Control Panel
+
+
+
+
+
+ )
+}
diff --git a/sysadmin_panel/src/pages/RegisterSitePage.jsx b/sysadmin_panel/src/pages/RegisterSitePage.jsx
new file mode 100644
index 0000000..f92c281
--- /dev/null
+++ b/sysadmin_panel/src/pages/RegisterSitePage.jsx
@@ -0,0 +1,196 @@
+import { useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import client from '../api/client'
+
+export default function RegisterSitePage() {
+ const navigate = useNavigate()
+ const [form, setForm] = useState({
+ name: '',
+ owner_name: '',
+ contact_email: '',
+ license_expires_at: '',
+ })
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState('')
+ const [created, setCreated] = useState(null) // { site_id, secret_key, name }
+ const [copied, setCopied] = useState(false)
+
+ function update(k, v) {
+ setForm(f => ({ ...f, [k]: v }))
+ }
+
+ async function handleSubmit(e) {
+ e.preventDefault()
+ if (!form.name.trim() || !form.owner_name.trim() || !form.contact_email.trim() || !form.license_expires_at) return
+ setError('')
+ setLoading(true)
+ try {
+ const { data } = await client.post('/api/sites/', {
+ name: form.name.trim(),
+ owner_name: form.owner_name.trim(),
+ contact_email: form.contact_email.trim(),
+ license_expires_at: new Date(form.license_expires_at).toISOString(),
+ })
+ setCreated(data)
+ } catch (err) {
+ setError(err.response?.data?.detail || 'Registration failed')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ async function copyAll() {
+ const text = `SITE_ID=${created.site_id}\nSITE_SECRET=${created.secret_key}`
+ await navigator.clipboard.writeText(text)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2500)
+ }
+
+ if (created) {
+ return (
+
+
+
+
+
Site Registered: {created.name}
+
+
+
+
+
+ Copy these credentials now. The secret key will never be shown again.
+
+
+
+
+
+
+
+ {created.site_id}
+
+
+
+
+
+ {created.secret_key}
+
+
+
+
+
+
Set these in the local backend .env:
+
SITE_ID={created.site_id}{'\n'}SITE_SECRET={created.secret_key}
+
+
+
+
+
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
Register New Site
+
+
+
+ )
+}
diff --git a/sysadmin_panel/src/pages/SiteDetailPage.jsx b/sysadmin_panel/src/pages/SiteDetailPage.jsx
new file mode 100644
index 0000000..76daf55
--- /dev/null
+++ b/sysadmin_panel/src/pages/SiteDetailPage.jsx
@@ -0,0 +1,293 @@
+import { useState, useEffect } from 'react'
+import { useParams, useNavigate } from 'react-router-dom'
+import toast from 'react-hot-toast'
+import client from '../api/client'
+import LicenseStatus from '../components/LicenseStatus'
+import ConfirmModal from '../components/ConfirmModal'
+
+function fmtDt(dt) {
+ if (!dt) return '—'
+ return new Date(dt).toLocaleString('en-GB', {
+ day: '2-digit', month: 'short', year: 'numeric',
+ hour: '2-digit', minute: '2-digit',
+ })
+}
+
+function fmtDate(dt) {
+ if (!dt) return ''
+ return new Date(dt).toISOString().slice(0, 10)
+}
+
+export default function SiteDetailPage() {
+ const { siteId } = useParams()
+ const navigate = useNavigate()
+
+ const [site, setSite] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+
+ const [modal, setModal] = useState(null) // 'lock' | 'unlock' | 'delete' | 'license'
+ const [lockReason, setLockReason] = useState('')
+ const [newExpiry, setNewExpiry] = useState('')
+ const [acting, setActing] = useState(false)
+
+ useEffect(() => {
+ async function load() {
+ try {
+ const { data } = await client.get(`/api/sites/${siteId}`)
+ setSite(data)
+ setNewExpiry(fmtDate(data.license_expires_at))
+ } catch {
+ setError('Site not found')
+ } finally {
+ setLoading(false)
+ }
+ }
+ load()
+ }, [siteId])
+
+ async function doLock() {
+ if (!lockReason.trim()) return
+ setActing(true)
+ try {
+ const { data } = await client.post(`/api/sites/${siteId}/lock`, { reason: lockReason.trim() })
+ setSite(data)
+ setModal(null)
+ setLockReason('')
+ toast.success('Site locked')
+ } catch (e) {
+ toast.error(e.response?.data?.detail || 'Failed to lock site')
+ } finally {
+ setActing(false)
+ }
+ }
+
+ async function doUnlock() {
+ setActing(true)
+ try {
+ const { data } = await client.post(`/api/sites/${siteId}/unlock`)
+ setSite(data)
+ setModal(null)
+ toast.success('Site unlocked')
+ } catch (e) {
+ toast.error(e.response?.data?.detail || 'Failed to unlock site')
+ } finally {
+ setActing(false)
+ }
+ }
+
+ async function doExtendLicense() {
+ if (!newExpiry) return
+ setActing(true)
+ try {
+ const { data } = await client.put(`/api/sites/${siteId}`, {
+ license_expires_at: new Date(newExpiry).toISOString(),
+ })
+ setSite(data)
+ setModal(null)
+ toast.success('License updated')
+ } catch (e) {
+ toast.error(e.response?.data?.detail || 'Failed to update license')
+ } finally {
+ setActing(false)
+ }
+ }
+
+ async function doDelete() {
+ setActing(true)
+ try {
+ await client.delete(`/api/sites/${siteId}`)
+ toast.success('Site deregistered')
+ navigate('/sites', { replace: true })
+ } catch (e) {
+ toast.error(e.response?.data?.detail || 'Failed to delete site')
+ setActing(false)
+ }
+ }
+
+ if (loading) return Loading…
+ if (error || !site) return {error || 'Not found'}
+
+ const expires = new Date(site.license_expires_at)
+ const isExpired = expires < new Date()
+
+ return (
+
+
+
+ {/* Header */}
+
+
+
{site.name}
+
{site.owner_name} · {site.contact_email}
+
+
+
+
+ {/* Site info */}
+
+
Site Info
+
+
+ Site ID
+ {site.site_id}
+
+
+ Registered
+ {fmtDt(site.created_at)}
+
+
+ Contact
+ {site.contact_email}
+
+
+
+
+ {/* License */}
+
+
+
License
+
+
+
+
+ Expires
+
+ {fmtDt(site.license_expires_at)}
+ {isExpired && ' (EXPIRED)'}
+
+
+
+
+
+ {/* Heartbeat */}
+
+
Heartbeat
+
+
+ Last seen
+ {fmtDt(site.last_seen_at)}
+
+
+ Last IP
+ {site.last_seen_ip || '—'}
+
+
+
+
+ {/* Lock status */}
+ {site.is_locked && (
+
+
Locked
+
{site.lock_reason || 'No reason given'}
+
+ )}
+
+ {/* Actions */}
+
+
Actions
+
+ {site.is_locked ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {/* Modals */}
+ {modal === 'lock' && (
+
{ setModal(null); setLockReason('') }}
+ onConfirm={doLock}
+ >
+
+ )}
+
+ {modal === 'unlock' && (
+
setModal(null)}
+ onConfirm={doUnlock}
+ />
+ )}
+
+ {modal === 'license' && (
+ setModal(null)}
+ onConfirm={doExtendLicense}
+ >
+
+
+ setNewExpiry(e.target.value)}
+ className="w-full bg-gray-800 border border-gray-600 text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-cyan-500"
+ />
+
+
+ )}
+
+ {modal === 'delete' && (
+ setModal(null)}
+ onConfirm={doDelete}
+ />
+ )}
+
+ )
+}
diff --git a/sysadmin_panel/src/pages/SitesPage.jsx b/sysadmin_panel/src/pages/SitesPage.jsx
new file mode 100644
index 0000000..dbc338b
--- /dev/null
+++ b/sysadmin_panel/src/pages/SitesPage.jsx
@@ -0,0 +1,97 @@
+import { useState, useEffect, useCallback } from 'react'
+import { useNavigate } from 'react-router-dom'
+import client from '../api/client'
+import SiteCard from '../components/SiteCard'
+
+export default function SitesPage() {
+ const [sites, setSites] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+ const navigate = useNavigate()
+
+ const fetchSites = useCallback(async () => {
+ try {
+ const { data } = await client.get('/api/sites/')
+ setSites(data)
+ setError('')
+ } catch {
+ setError('Failed to load sites')
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => {
+ fetchSites()
+ const id = setInterval(fetchSites, 30000)
+ return () => clearInterval(id)
+ }, [fetchSites])
+
+ const active = sites.filter(s => !s.is_locked && new Date(s.license_expires_at) > new Date()).length
+ const locked = sites.filter(s => s.is_locked).length
+ const expired = sites.filter(s => !s.is_locked && new Date(s.license_expires_at) <= new Date()).length
+
+ return (
+
+
+
+
Sites
+
{sites.length} registered
+
+
+
+
+ {/* Summary row */}
+ {sites.length > 0 && (
+
+ )}
+
+ {loading && (
+
Loading…
+ )}
+
+ {error && (
+
{error}
+ )}
+
+ {!loading && !error && sites.length === 0 && (
+
+
No sites registered yet.
+
+
+ )}
+
+
+ {sites.map(site => (
+
+ ))}
+
+
+ )
+}
diff --git a/sysadmin_panel/src/store/authStore.js b/sysadmin_panel/src/store/authStore.js
new file mode 100644
index 0000000..fd2fa4b
--- /dev/null
+++ b/sysadmin_panel/src/store/authStore.js
@@ -0,0 +1,17 @@
+import { create } from 'zustand'
+
+const useAuthStore = create((set) => ({
+ token: localStorage.getItem('sysadmin_token') || null,
+
+ login(token) {
+ localStorage.setItem('sysadmin_token', token)
+ set({ token })
+ },
+
+ logout() {
+ localStorage.removeItem('sysadmin_token')
+ set({ token: null })
+ },
+}))
+
+export default useAuthStore
diff --git a/sysadmin_panel/tailwind.config.js b/sysadmin_panel/tailwind.config.js
new file mode 100644
index 0000000..2440c1e
--- /dev/null
+++ b/sysadmin_panel/tailwind.config.js
@@ -0,0 +1,17 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: ['./index.html', './src/**/*.{js,jsx}'],
+ theme: {
+ extend: {
+ colors: {
+ cyan: {
+ 400: '#22d3ee',
+ 500: '#06b6d4',
+ 600: '#0891b2',
+ 700: '#0e7490',
+ },
+ },
+ },
+ },
+ plugins: [],
+}
diff --git a/sysadmin_panel/vite.config.js b/sysadmin_panel/vite.config.js
new file mode 100644
index 0000000..f245831
--- /dev/null
+++ b/sysadmin_panel/vite.config.js
@@ -0,0 +1,10 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ port: 5175,
+ host: '0.0.0.0',
+ },
+})