tablet-checkin/frontend/src/pages/Terminals.tsx
jtricerolph 67def8ac19 Move extension download from Settings to Terminals page
Makes more sense there — creating a terminal key and installing the
extension are the same setup workflow, not two unrelated things on
separate pages.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 20:35:54 +00:00

153 lines
6.4 KiB
TypeScript

import { useEffect, useState } from 'react'
import { RefreshCw, KeyRound, Ban, Trash2, Copy, Download } from 'lucide-react'
import { fetchTerminals, createTerminal, revokeTerminal, deleteTerminal, extensionDownloadUrl } from '../api'
import type { Terminal } from '../types'
export default function Terminals() {
const [terminals, setTerminals] = useState<Terminal[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [newName, setNewName] = useState('')
const [creating, setCreating] = useState(false)
const [revealedKey, setRevealedKey] = useState<{ name: string; key: string } | null>(null)
function load() {
setLoading(true)
fetchTerminals()
.then(setTerminals)
.catch(e => setError(e instanceof Error ? e.message : 'Failed to load terminals'))
.finally(() => setLoading(false))
}
useEffect(load, [])
async function handleCreate(e: React.FormEvent) {
e.preventDefault()
if (!newName.trim()) return
setCreating(true); setError('')
try {
const t = await createTerminal(newName.trim())
setRevealedKey({ name: t.name, key: t.api_key })
setNewName('')
load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Create failed')
} finally {
setCreating(false)
}
}
async function handleRevoke(id: string) {
if (!confirm('Revoke this terminal key? The Chrome extension using it will stop working immediately.')) return
try {
await revokeTerminal(id)
load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Revoke failed')
}
}
async function handleDelete(id: string) {
if (!confirm('Delete this terminal permanently?')) return
try {
await deleteTerminal(id)
load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Delete failed')
}
}
return (
<div className="page">
<div className="page-header">
<h1>Terminals</h1>
<button className="btn" onClick={load}><RefreshCw size={14} strokeWidth={1.75} /> Refresh</button>
</div>
<p className="field-hint" style={{ marginBottom: 14 }}>
API keys for the standalone "Send to Check-in Tablet" Chrome extension — one per reception PC.
The extension calls this app's push endpoint using a terminal key instead of a staff login
(hotel-owned hardware gets a device credential, same trust tier as the tablets themselves).
</p>
<div className="card" style={{ marginBottom: 20 }}>
<div style={{ fontWeight: 600, marginBottom: 6 }}>Chrome Extension</div>
<p className="field-hint" style={{ marginBottom: 10 }}>
Not published anywhere yet, so it needs installing manually on each reception PC: download,
unzip, then <code>chrome://extensions</code> → enable Developer mode → Load unpacked →
select the unzipped folder. It'll need a terminal key from below to actually authorize
pushes — create one first if you haven't already.
</p>
<a className="btn" href={extensionDownloadUrl()} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, textDecoration: 'none' }}>
<Download size={16} strokeWidth={1.75} /> Download Extension (.zip)
</a>
</div>
{error && <div className="error-banner">{error}</div>}
{revealedKey && (
<div className="card" style={{ borderColor: 'var(--gold)', marginBottom: 16 }}>
<div style={{ fontWeight: 600, marginBottom: 6 }}>
<KeyRound size={14} strokeWidth={1.75} style={{ verticalAlign: '-2px' }} /> Key for "{revealedKey.name}"
</div>
<p className="field-hint" style={{ marginBottom: 8 }}>
Copy this now and paste it into the extension's settings popup — it will never be shown again.
</p>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<code style={{ flex: 1, wordBreak: 'break-all', fontSize: 12, background: 'var(--body-bg)', padding: '6px 8px', borderRadius: 6 }}>
{revealedKey.key}
</code>
<button className="btn btn-sm" onClick={() => navigator.clipboard.writeText(revealedKey.key)}>
<Copy size={12} strokeWidth={1.75} /> Copy
</button>
</div>
<button className="btn btn-sm" style={{ marginTop: 10 }} onClick={() => setRevealedKey(null)}>Done</button>
</div>
)}
<form onSubmit={handleCreate} style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
<input
type="text" placeholder="e.g. Reception PC 1" value={newName}
onChange={e => setNewName(e.target.value)}
style={{ flex: 1, border: '1px solid var(--card-border)', borderRadius: 8, padding: '8px 10px' }}
/>
<button className="btn btn-primary" type="submit" disabled={creating}>
{creating ? 'Creating…' : 'Create Terminal'}
</button>
</form>
{loading ? <p className="muted">Loading…</p> : terminals.length === 0 ? (
<div className="empty-state">No terminals yet.</div>
) : (
<div className="table-wrap">
<table className="data">
<thead><tr><th>Name</th><th>Created</th><th>Last Used</th><th>Status</th><th></th></tr></thead>
<tbody>
{terminals.map(t => (
<tr key={t.terminal_id}>
<td>{t.name}</td>
<td>{new Date(t.created_at).toLocaleDateString('en-GB')}</td>
<td>{t.last_used_at ? new Date(t.last_used_at).toLocaleString('en-GB') : 'never'}</td>
<td>
{t.revoked_at
? <span className="badge badge-health-unresponsive">Revoked</span>
: <span className="badge badge-health-healthy">Active</span>}
</td>
<td style={{ display: 'flex', gap: 6 }}>
{!t.revoked_at && (
<button className="btn btn-sm" onClick={() => handleRevoke(t.terminal_id)}>
<Ban size={12} strokeWidth={1.75} /> Revoke
</button>
)}
<button className="btn btn-sm btn-danger" onClick={() => handleDelete(t.terminal_id)}>
<Trash2 size={12} strokeWidth={1.75} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}