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

+
+ +
+
+ + setUsername(e.target.value)} + autoComplete="username" + autoFocus + className="w-full bg-gray-800 border border-gray-600 text-white rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-1 focus:ring-cyan-500 focus:border-cyan-500 placeholder-gray-600" + placeholder="admin" + /> +
+
+ + setPassword(e.target.value)} + autoComplete="current-password" + className="w-full bg-gray-800 border border-gray-600 text-white rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-1 focus:ring-cyan-500 focus:border-cyan-500 placeholder-gray-600" + placeholder="••••••••" + /> +
+ + {error && ( +

+ {error} +

+ )} + + +
+
+
+ ) +} 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

+ +
+
+ + update('name', e.target.value)} + className="w-full bg-gray-800 border border-gray-600 text-white rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-1 focus:ring-cyan-500 focus:border-cyan-500 placeholder-gray-600" + placeholder="e.g. Taverna Kostas" + required + /> +
+ +
+ + update('owner_name', e.target.value)} + className="w-full bg-gray-800 border border-gray-600 text-white rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-1 focus:ring-cyan-500 focus:border-cyan-500 placeholder-gray-600" + placeholder="e.g. Kostas Papadopoulos" + required + /> +
+ +
+ + update('contact_email', e.target.value)} + className="w-full bg-gray-800 border border-gray-600 text-white rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-1 focus:ring-cyan-500 focus:border-cyan-500 placeholder-gray-600" + placeholder="kostas@example.com" + required + /> +
+ +
+ + update('license_expires_at', e.target.value)} + className="w-full bg-gray-800 border border-gray-600 text-white rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-1 focus:ring-cyan-500 focus:border-cyan-500" + required + /> +
+ + {error && ( +

{error}

+ )} + + +
+
+ ) +} 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} + > +