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>
153 lines
6.4 KiB
TypeScript
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>
|
|
)
|
|
}
|