Add Python agent, WebSocket relay, real viewer, enrollment tokens

- WebSocket relay service (FastAPI) bridges agents and viewers
- Python agent with screen capture (mss), input control (pynput),
  script execution, and auto-reconnect
- Windows service wrapper, PyInstaller spec, NSIS installer for
  silent mass deployment (RemoteLink-Setup.exe /S /SERVER= /ENROLL=)
- Enrollment token system: admin generates tokens, agents self-register
- Real WebSocket viewer replaces simulated canvas
- Linux agent binary served from /downloads/remotelink-agent-linux
- DB migration 0002: viewer_token on sessions, enrollment_tokens table
- Sign-up pages cleaned up (invite-only redirect)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
monoadmin
2026-04-10 16:25:10 -07:00
parent b2be19ed14
commit e16a2fa978
28 changed files with 1953 additions and 343 deletions

View File

@@ -11,7 +11,34 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { UserPlus, Copy, Check, Trash2, Clock, CheckCircle2, Loader2 } from 'lucide-react'
import {
UserPlus, Copy, Check, Trash2, Clock, CheckCircle2,
Loader2, KeyRound, Ban, Infinity, Hash,
} from 'lucide-react'
// ── Shared helper ─────────────────────────────────────────────────────────────
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false)
return (
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={async () => {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}}
>
{copied
? <Check className="h-3.5 w-3.5 text-green-500" />
: <Copy className="h-3.5 w-3.5" />}
</Button>
)
}
// ── Invites ───────────────────────────────────────────────────────────────────
interface Invite {
id: string
@@ -28,30 +55,14 @@ function inviteStatus(invite: Invite): 'used' | 'expired' | 'pending' {
return 'pending'
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<Button variant="ghost" size="sm" onClick={handleCopy} className="h-7 px-2">
{copied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
</Button>
)
}
export default function AdminPage() {
function InvitesSection() {
const [invites, setInvites] = useState<Invite[]>([])
const [email, setEmail] = useState('')
const [isLoading, setIsLoading] = useState(true)
const [isCreating, setIsCreating] = useState(false)
const [error, setError] = useState<string | null>(null)
const [successEmail, setSuccessEmail] = useState<string | null>(null)
const [newToken, setNewToken] = useState<string | null>(null)
const [successEmail, setSuccessEmail] = useState<string | null>(null)
const fetchInvites = useCallback(async () => {
try {
@@ -63,17 +74,12 @@ export default function AdminPage() {
}
}, [])
useEffect(() => {
fetchInvites()
}, [fetchInvites])
useEffect(() => { fetchInvites() }, [fetchInvites])
const handleCreate = async (e: React.FormEvent) => {
const handleCreate = async (e: React.SyntheticEvent<HTMLFormElement>) => {
e.preventDefault()
setError(null)
setSuccessEmail(null)
setNewToken(null)
setError(null); setSuccessEmail(null); setNewToken(null)
setIsCreating(true)
try {
const res = await fetch('/api/invites', {
method: 'POST',
@@ -81,12 +87,7 @@ export default function AdminPage() {
body: JSON.stringify({ email }),
})
const data = await res.json()
if (!res.ok) {
setError(data.error)
return
}
if (!res.ok) { setError(data.error); return }
setSuccessEmail(email)
setNewToken(data.invite.token)
setEmail('')
@@ -99,95 +100,48 @@ export default function AdminPage() {
}
const handleDelete = async (id: string) => {
const res = await fetch('/api/invites', {
await fetch('/api/invites', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
})
if (res.ok) await fetchInvites()
await fetchInvites()
}
const inviteUrl = (token: string) =>
`${window.location.origin}/auth/invite/${token}`
const inviteUrl = (token: string) => `${window.location.origin}/auth/invite/${token}`
const statusBadge = (invite: Invite) => {
const status = inviteStatus(invite)
if (status === 'used')
return (
<span className="flex items-center gap-1 text-xs text-green-500">
<CheckCircle2 className="h-3 w-3" /> Used
</span>
)
if (status === 'expired')
return (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" /> Expired
</span>
)
return (
<span className="flex items-center gap-1 text-xs text-primary">
<Clock className="h-3 w-3" /> Pending
</span>
)
const s = inviteStatus(invite)
if (s === 'used') return <span className="flex items-center gap-1 text-xs text-green-500"><CheckCircle2 className="h-3 w-3" /> Used</span>
if (s === 'expired') return <span className="flex items-center gap-1 text-xs text-muted-foreground"><Clock className="h-3 w-3" /> Expired</span>
return <span className="flex items-center gap-1 text-xs text-primary"><Clock className="h-3 w-3" /> Pending</span>
}
return (
<div className="space-y-6 max-w-2xl">
<div>
<h2 className="text-2xl font-bold">Admin</h2>
<p className="text-muted-foreground">Manage user invitations</p>
</div>
{/* Create invite */}
<div className="space-y-4">
<Card className="border-border/50">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UserPlus className="h-5 w-5" />
Invite user
</CardTitle>
<CardDescription>
Send an invite link to a new user. Links expire after 7 days.
</CardDescription>
<CardTitle className="flex items-center gap-2"><UserPlus className="h-5 w-5" />Invite user</CardTitle>
<CardDescription>Send an invite link to a new user. Links expire after 7 days.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleCreate} className="space-y-4">
<div className="grid gap-2">
<Label htmlFor="email">Email address</Label>
<div className="flex gap-2">
<Input
id="email"
type="email"
placeholder="user@company.com"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="bg-secondary/50"
/>
<Input id="email" type="email" placeholder="user@company.com" required value={email}
onChange={(e) => setEmail(e.target.value)} className="bg-secondary/50" />
<Button type="submit" disabled={isCreating}>
{isCreating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Send invite'
)}
{isCreating ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Send invite'}
</Button>
</div>
</div>
{error && (
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-3">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
{error && <div className="rounded-md bg-destructive/10 border border-destructive/20 p-3"><p className="text-sm text-destructive">{error}</p></div>}
{newToken && successEmail && (
<div className="rounded-md bg-green-500/10 border border-green-500/20 p-3 space-y-2">
<p className="text-sm text-green-500 font-medium">
Invite created for {successEmail}
</p>
<p className="text-sm text-green-500 font-medium">Invite created for {successEmail}</p>
<div className="flex items-center gap-2">
<code className="text-xs text-muted-foreground truncate flex-1 bg-muted/50 px-2 py-1 rounded">
{inviteUrl(newToken)}
</code>
<code className="text-xs text-muted-foreground truncate flex-1 bg-muted/50 px-2 py-1 rounded">{inviteUrl(newToken)}</code>
<CopyButton text={inviteUrl(newToken)} />
</div>
</div>
@@ -196,7 +150,6 @@ export default function AdminPage() {
</CardContent>
</Card>
{/* Invites list */}
<Card className="border-border/50">
<CardHeader>
<CardTitle>Invitations</CardTitle>
@@ -204,41 +157,26 @@ export default function AdminPage() {
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
<div className="flex justify-center py-8"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : invites.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No invitations yet
</p>
<p className="text-sm text-muted-foreground text-center py-8">No invitations yet</p>
) : (
<div className="space-y-2">
{invites.map((invite) => {
const status = inviteStatus(invite)
return (
<div
key={invite.id}
className="flex items-center justify-between p-3 rounded-lg bg-muted/30"
>
<div key={invite.id} className="flex items-center justify-between p-3 rounded-lg bg-muted/30">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{invite.email}</p>
<div className="flex items-center gap-3 mt-0.5">
{statusBadge(invite)}
<span className="text-xs text-muted-foreground">
{new Date(invite.created_at).toLocaleDateString()}
</span>
<span className="text-xs text-muted-foreground">{new Date(invite.created_at).toLocaleDateString()}</span>
</div>
</div>
<div className="flex items-center gap-1 ml-3">
{status === 'pending' && (
<CopyButton text={inviteUrl(invite.token)} />
)}
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-muted-foreground hover:text-destructive"
onClick={() => handleDelete(invite.id)}
>
{status === 'pending' && <CopyButton text={inviteUrl(invite.token)} />}
<Button variant="ghost" size="sm" className="h-7 px-2 text-muted-foreground hover:text-destructive"
onClick={() => handleDelete(invite.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
@@ -252,3 +190,269 @@ export default function AdminPage() {
</div>
)
}
// ── Enrollment tokens ─────────────────────────────────────────────────────────
interface EnrollmentToken {
id: string
token: string
label: string | null
created_at: string
expires_at: string | null
used_count: number
max_uses: number | null
revoked_at: string | null
}
function tokenStatus(t: EnrollmentToken): 'active' | 'revoked' | 'expired' | 'exhausted' {
if (t.revoked_at) return 'revoked'
if (t.expires_at && new Date(t.expires_at) < new Date()) return 'expired'
if (t.max_uses !== null && t.used_count >= t.max_uses) return 'exhausted'
return 'active'
}
function EnrollmentSection() {
const [tokens, setTokens] = useState<EnrollmentToken[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isCreating, setIsCreating] = useState(false)
const [error, setError] = useState<string | null>(null)
const [newToken, setNewToken] = useState<EnrollmentToken | null>(null)
// form state
const [label, setLabel] = useState('')
const [expiresInDays, setExpiresInDays] = useState('')
const [maxUses, setMaxUses] = useState('')
const fetchTokens = useCallback(async () => {
try {
const res = await fetch('/api/enrollment-tokens')
const data = await res.json()
if (res.ok) setTokens(data.tokens)
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => { fetchTokens() }, [fetchTokens])
const handleCreate = async (e: React.SyntheticEvent<HTMLFormElement>) => {
e.preventDefault()
setError(null); setNewToken(null)
setIsCreating(true)
try {
const res = await fetch('/api/enrollment-tokens', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
label: label || undefined,
expiresInDays: expiresInDays ? parseInt(expiresInDays) : undefined,
maxUses: maxUses ? parseInt(maxUses) : undefined,
}),
})
const data = await res.json()
if (!res.ok) { setError(data.error); return }
setNewToken(data.token)
setLabel(''); setExpiresInDays(''); setMaxUses('')
await fetchTokens()
} catch {
setError('Network error. Please try again.')
} finally {
setIsCreating(false)
}
}
const handleRevoke = async (id: string) => {
await fetch('/api/enrollment-tokens', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
})
await fetchTokens()
}
const statusBadge = (t: EnrollmentToken) => {
const s = tokenStatus(t)
const styles: Record<string, string> = {
active: 'text-green-500',
revoked: 'text-destructive',
expired: 'text-muted-foreground',
exhausted: 'text-yellow-500',
}
const labels: Record<string, string> = {
active: 'Active',
revoked: 'Revoked',
expired: 'Expired',
exhausted: 'Exhausted',
}
return <span className={`text-xs font-medium ${styles[s]}`}>{labels[s]}</span>
}
const agentCommand = (token: string) =>
`python agent.py --server ${window.location.origin} --enroll ${token}`
return (
<div className="space-y-4">
<Card className="border-border/50">
<CardHeader>
<CardTitle className="flex items-center gap-2"><KeyRound className="h-5 w-5" />Create enrollment token</CardTitle>
<CardDescription>
Tokens let agents self-register. Share the token or use it in a silent installer for mass deployment.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleCreate} className="space-y-4">
<div className="grid gap-2">
<Label htmlFor="token-label">Label <span className="text-muted-foreground font-normal">(optional)</span></Label>
<Input id="token-label" placeholder="e.g. Office PCs batch 1"
value={label} onChange={(e) => setLabel(e.target.value)} className="bg-secondary/50" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-2">
<Label htmlFor="expires">Expires after <span className="text-muted-foreground font-normal">(days, blank = never)</span></Label>
<Input id="expires" type="number" min="1" placeholder="Never"
value={expiresInDays} onChange={(e) => setExpiresInDays(e.target.value)} className="bg-secondary/50" />
</div>
<div className="grid gap-2">
<Label htmlFor="max-uses">Max uses <span className="text-muted-foreground font-normal">(blank = unlimited)</span></Label>
<Input id="max-uses" type="number" min="1" placeholder="Unlimited"
value={maxUses} onChange={(e) => setMaxUses(e.target.value)} className="bg-secondary/50" />
</div>
</div>
{error && <div className="rounded-md bg-destructive/10 border border-destructive/20 p-3"><p className="text-sm text-destructive">{error}</p></div>}
<Button type="submit" disabled={isCreating}>
{isCreating ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Generate token'}
</Button>
</form>
{newToken && (
<div className="mt-4 rounded-md bg-green-500/10 border border-green-500/20 p-4 space-y-3">
<p className="text-sm text-green-500 font-medium">Token created</p>
<div>
<p className="text-xs text-muted-foreground mb-1">Token</p>
<div className="flex items-center gap-2">
<code className="text-xs bg-muted/50 px-2 py-1 rounded flex-1 truncate font-mono">{newToken.token}</code>
<CopyButton text={newToken.token} />
</div>
</div>
<div>
<p className="text-xs text-muted-foreground mb-1">Agent command</p>
<div className="flex items-center gap-2">
<code className="text-xs bg-muted/50 px-2 py-1 rounded flex-1 truncate font-mono">{agentCommand(newToken.token)}</code>
<CopyButton text={agentCommand(newToken.token)} />
</div>
</div>
<div>
<p className="text-xs text-muted-foreground mb-1">Silent Windows installer</p>
<div className="flex items-center gap-2">
<code className="text-xs bg-muted/50 px-2 py-1 rounded flex-1 truncate font-mono">
{`RemoteLink-Setup.exe /S /SERVER=${window.location.origin} /ENROLL=${newToken.token}`}
</code>
<CopyButton text={`RemoteLink-Setup.exe /S /SERVER=${window.location.origin} /ENROLL=${newToken.token}`} />
</div>
</div>
</div>
)}
</CardContent>
</Card>
<Card className="border-border/50">
<CardHeader>
<CardTitle>Enrollment tokens</CardTitle>
<CardDescription>Manage tokens used to register new agents</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex justify-center py-8"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : tokens.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">No enrollment tokens yet</p>
) : (
<div className="space-y-2">
{tokens.map((t) => {
const status = tokenStatus(t)
const isActive = status === 'active'
return (
<div key={t.id} className={`flex items-center justify-between p-3 rounded-lg bg-muted/30 ${!isActive ? 'opacity-60' : ''}`}>
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">{t.label || <span className="text-muted-foreground italic">Unlabelled</span>}</p>
{statusBadge(t)}
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="font-mono truncate max-w-[160px]">{t.token}</span>
<span className="flex items-center gap-1">
<Hash className="h-3 w-3" />
{t.used_count} use{t.used_count !== 1 ? 's' : ''}
{t.max_uses !== null ? ` / ${t.max_uses}` : ''}
</span>
{t.expires_at ? (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{new Date(t.expires_at) > new Date()
? `Expires ${new Date(t.expires_at).toLocaleDateString()}`
: `Expired ${new Date(t.expires_at).toLocaleDateString()}`}
</span>
) : (
<span className="flex items-center gap-1"><Infinity className="h-3 w-3" /> No expiry</span>
)}
</div>
</div>
<div className="flex items-center gap-1 ml-3 shrink-0">
{isActive && <CopyButton text={t.token} />}
{isActive && (
<Button variant="ghost" size="sm" className="h-7 px-2 text-muted-foreground hover:text-destructive"
onClick={() => handleRevoke(t.id)} title="Revoke token">
<Ban className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
</div>
)
}
// ── Page ──────────────────────────────────────────────────────────────────────
type Tab = 'invites' | 'enrollment'
export default function AdminPage() {
const [tab, setTab] = useState<Tab>('invites')
return (
<div className="space-y-6 max-w-2xl">
<div>
<h2 className="text-2xl font-bold">Admin</h2>
<p className="text-muted-foreground">Manage users and agent deployment</p>
</div>
{/* Tab bar */}
<div className="flex gap-1 p-1 rounded-lg bg-muted/50 w-fit">
{(['invites', 'enrollment'] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-4 py-1.5 rounded-md text-sm font-medium transition-colors ${
tab === t
? 'bg-background shadow-sm text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{t === 'invites' ? 'User invites' : 'Agent enrollment'}
</button>
))}
</div>
{tab === 'invites' ? <InvitesSection /> : <EnrollmentSection />}
</div>
)
}

View File

@@ -42,7 +42,7 @@ export default function ConnectPage() {
return
}
router.push(`/viewer/${data.sessionId}`)
router.push(`/viewer/${data.sessionId}?token=${data.viewerToken}`)
} catch {
setError('Network error. Please try again.')
setIsConnecting(false)

View File

@@ -1,14 +1,7 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import {
Download,
Monitor,
Apple,
Terminal,
Shield,
Cpu,
HardDrive
} from 'lucide-react'
import { Download, Monitor, Apple, Terminal, Shield, Cpu, HardDrive, Clock } from 'lucide-react'
import Link from 'next/link'
const platforms = [
{
@@ -16,43 +9,35 @@ const platforms = [
icon: Monitor,
description: 'Windows 10/11 (64-bit)',
filename: 'RemoteLink-Setup.exe',
size: '45 MB',
available: true,
downloadPath: null,
available: false,
note: 'Coming soon — build on Windows with PyInstaller + NSIS',
},
{
name: 'macOS',
icon: Apple,
description: 'macOS 11+ (Apple Silicon & Intel)',
filename: 'RemoteLink.dmg',
size: '52 MB',
available: true,
filename: 'remotelink-agent-macos',
downloadPath: null,
available: false,
note: 'Coming soon — build on macOS with PyInstaller',
},
{
name: 'Linux',
icon: Terminal,
description: 'Ubuntu, Debian, Fedora, Arch',
filename: 'remotelink-agent.AppImage',
size: '48 MB',
description: 'x86_64 — Ubuntu, Debian, Fedora, Arch',
filename: 'remotelink-agent-linux',
downloadPath: '/downloads/remotelink-agent-linux',
available: true,
size: '19 MB',
note: null,
},
]
const features = [
{
icon: Shield,
title: 'Secure',
description: 'End-to-end encryption for all connections',
},
{
icon: Cpu,
title: 'Lightweight',
description: 'Minimal system resource usage',
},
{
icon: HardDrive,
title: 'Portable',
description: 'No installation required on Windows',
},
{ icon: Shield, title: 'Secure', description: 'All traffic routed through your own relay server' },
{ icon: Cpu, title: 'Lightweight', description: 'Single binary, minimal CPU and memory usage' },
{ icon: HardDrive, title: 'Portable', description: 'Run once with no install, or deploy as a service' },
]
export default function DownloadPage() {
@@ -61,17 +46,17 @@ export default function DownloadPage() {
<div className="text-center">
<h2 className="text-2xl font-bold mb-2">Download RemoteLink Agent</h2>
<p className="text-muted-foreground max-w-xl mx-auto text-balance">
Install the agent on machines you want to control remotely.
The agent runs in the background and enables secure connections.
Install the agent on machines you want to control remotely.
It connects back to your server and waits for a viewer session.
</p>
</div>
<div className="grid gap-4 md:grid-cols-3">
{platforms.map((platform) => (
<Card key={platform.name} className="border-border/50">
<Card key={platform.name} className={`border-border/50 ${!platform.available ? 'opacity-60' : ''}`}>
<CardHeader className="text-center pb-2">
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-xl bg-primary/10">
<platform.icon className="h-7 w-7 text-primary" />
<div className={`mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-xl ${platform.available ? 'bg-primary/10' : 'bg-muted'}`}>
<platform.icon className={`h-7 w-7 ${platform.available ? 'text-primary' : 'text-muted-foreground'}`} />
</div>
<CardTitle>{platform.name}</CardTitle>
<CardDescription>{platform.description}</CardDescription>
@@ -79,15 +64,26 @@ export default function DownloadPage() {
<CardContent className="space-y-4">
<div className="text-center text-sm text-muted-foreground">
<p className="font-mono">{platform.filename}</p>
<p>{platform.size}</p>
{platform.available && 'size' in platform && <p>{platform.size}</p>}
</div>
<Button
className="w-full"
disabled={!platform.available}
>
<Download className="mr-2 h-4 w-4" />
Download
</Button>
{platform.available && platform.downloadPath ? (
<Button className="w-full" asChild>
<a href={platform.downloadPath} download={platform.filename}>
<Download className="mr-2 h-4 w-4" />
Download
</a>
</Button>
) : (
<Button className="w-full" disabled>
<Clock className="mr-2 h-4 w-4" />
Coming soon
</Button>
)}
{platform.note && (
<p className="text-xs text-muted-foreground text-center">{platform.note}</p>
)}
</CardContent>
</Card>
))}
@@ -118,30 +114,27 @@ export default function DownloadPage() {
</CardHeader>
<CardContent className="space-y-6">
<div>
<h4 className="font-medium mb-2">Windows</h4>
<h4 className="font-medium mb-2">Linux run once (portable)</h4>
<ol className="list-decimal list-inside space-y-1 text-sm text-muted-foreground">
<li>Download and run RemoteLink-Setup.exe</li>
<li>Follow the installation wizard</li>
<li>The agent will start automatically and appear in your system tray</li>
<li>Click the tray icon to generate a session code</li>
<li>Download <code className="bg-muted px-1 rounded">remotelink-agent-linux</code></li>
<li>Make it executable: <code className="bg-muted px-1 rounded">chmod +x remotelink-agent-linux</code></li>
<li>Get an enrollment token from <Link href="/dashboard/admin" className="text-primary underline underline-offset-2">Admin Agent enrollment</Link></li>
<li>Run: <code className="bg-muted px-1 rounded">./remotelink-agent-linux --server https://your-server --enroll YOUR_TOKEN --run-once</code></li>
</ol>
</div>
<div>
<h4 className="font-medium mb-2">macOS</h4>
<h4 className="font-medium mb-2">Linux permanent install (reconnects on reboot)</h4>
<ol className="list-decimal list-inside space-y-1 text-sm text-muted-foreground">
<li>Download and open RemoteLink.dmg</li>
<li>Drag RemoteLink to your Applications folder</li>
<li>Open RemoteLink from Applications</li>
<li>Grant accessibility permissions when prompted</li>
<li>Run without <code className="bg-muted px-1 rounded">--run-once</code> config is saved to <code className="bg-muted px-1 rounded">/etc/remotelink/agent.json</code></li>
<li>Create a systemd service or add to crontab with <code className="bg-muted px-1 rounded">@reboot</code></li>
</ol>
</div>
<div>
<h4 className="font-medium mb-2">Linux</h4>
<h4 className="font-medium mb-2">Windows silent mass deploy (coming soon)</h4>
<ol className="list-decimal list-inside space-y-1 text-sm text-muted-foreground">
<li>Download the AppImage file</li>
<li>Make it executable: <code className="bg-muted px-1 rounded">chmod +x remotelink-agent.AppImage</code></li>
<li>Run the AppImage</li>
<li>The agent will appear in your system tray</li>
<li>Build <code className="bg-muted px-1 rounded">RemoteLink-Setup.exe</code> on a Windows machine using the NSIS installer script in the agent source</li>
<li>Deploy silently: <code className="bg-muted px-1 rounded">RemoteLink-Setup.exe /S /SERVER=https://your-server /ENROLL=YOUR_TOKEN</code></li>
<li>The installer registers a Windows Service that auto-starts on boot</li>
</ol>
</div>
</CardContent>

View File

@@ -1,44 +1,112 @@
import { db } from '@/lib/db'
import { machines } from '@/lib/db/schema'
import { eq } from 'drizzle-orm'
import { machines, enrollmentTokens } from '@/lib/db/schema'
import { eq, and, isNull, or, gt } from 'drizzle-orm'
import { NextRequest, NextResponse } from 'next/server'
import { randomBytes } from 'crypto'
// POST /api/agent/register
// Two modes:
// 1. First-time: { enrollmentToken, name, hostname, os, osVersion, agentVersion, ipAddress }
// → creates machine, returns { machineId, accessKey }
// 2. Re-register: { accessKey, name, hostname, os, osVersion, agentVersion, ipAddress }
// → updates existing machine, returns { machineId }
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { accessKey, name, hostname, os, osVersion, agentVersion, ipAddress } = body
const { accessKey, enrollmentToken, name, hostname, os, osVersion, agentVersion, ipAddress } = body
if (!accessKey) {
return NextResponse.json({ error: 'Access key required' }, { status: 400 })
// ── Mode 2: existing agent re-registering ─────────────────────────────────
if (accessKey) {
const result = await db
.select()
.from(machines)
.where(eq(machines.accessKey, accessKey))
.limit(1)
const machine = result[0]
if (!machine) {
return NextResponse.json({ error: 'Invalid access key' }, { status: 401 })
}
await db
.update(machines)
.set({
name: name || machine.name,
hostname: hostname || machine.hostname,
os: os || machine.os,
osVersion: osVersion || machine.osVersion,
agentVersion: agentVersion || machine.agentVersion,
ipAddress: ipAddress || machine.ipAddress,
isOnline: true,
lastSeen: new Date(),
updatedAt: new Date(),
})
.where(eq(machines.id, machine.id))
return NextResponse.json({ machineId: machine.id })
}
const result = await db
// ── Mode 1: first-time registration with enrollment token ─────────────────
if (!enrollmentToken) {
return NextResponse.json({ error: 'accessKey or enrollmentToken required' }, { status: 400 })
}
const tokenResult = await db
.select()
.from(machines)
.where(eq(machines.accessKey, accessKey))
.from(enrollmentTokens)
.where(eq(enrollmentTokens.token, enrollmentToken))
.limit(1)
const machine = result[0]
if (!machine) {
return NextResponse.json({ error: 'Invalid access key' }, { status: 401 })
const token = tokenResult[0]
if (!token) {
return NextResponse.json({ error: 'Invalid enrollment token' }, { status: 401 })
}
await db
.update(machines)
.set({
name: name || machine.name,
hostname: hostname || machine.hostname,
os: os || machine.os,
osVersion: osVersion || machine.osVersion,
agentVersion: agentVersion || machine.agentVersion,
ipAddress: ipAddress || machine.ipAddress,
// Check revoked
if (token.revokedAt) {
return NextResponse.json({ error: 'Enrollment token has been revoked' }, { status: 401 })
}
// Check expiry
if (token.expiresAt && token.expiresAt < new Date()) {
return NextResponse.json({ error: 'Enrollment token has expired' }, { status: 401 })
}
// Check max uses
if (token.maxUses !== null && token.usedCount >= token.maxUses) {
return NextResponse.json({ error: 'Enrollment token has reached its use limit' }, { status: 401 })
}
if (!name) {
return NextResponse.json({ error: 'name is required for first-time registration' }, { status: 400 })
}
// Generate a secure access key for this machine
const newAccessKey = randomBytes(32).toString('hex')
const newMachine = await db
.insert(machines)
.values({
userId: token.createdBy!,
name,
hostname,
os,
osVersion,
agentVersion,
ipAddress,
accessKey: newAccessKey,
isOnline: true,
lastSeen: new Date(),
updatedAt: new Date(),
})
.where(eq(machines.id, machine.id))
.returning({ id: machines.id })
return NextResponse.json({ success: true, machineId: machine.id })
// Increment use count
await db
.update(enrollmentTokens)
.set({ usedCount: token.usedCount + 1 })
.where(eq(enrollmentTokens.id, token.id))
return NextResponse.json({ machineId: newMachine[0].id, accessKey: newAccessKey })
} catch (error) {
console.error('[Agent Register] Error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })

View File

@@ -51,7 +51,7 @@ export async function POST(request: NextRequest) {
.set({ usedAt: new Date(), usedBy: session.user.id, isActive: false })
.where(eq(sessionCodes.id, sessionCode.id))
// Create session record
// Create session record (viewer_token is auto-generated by DB default)
const newSession = await db
.insert(sessions)
.values({
@@ -61,7 +61,11 @@ export async function POST(request: NextRequest) {
connectionType: 'session_code',
sessionCode: normalizedCode,
})
.returning({ id: sessions.id })
.returning({ id: sessions.id, viewerToken: sessions.viewerToken })
return NextResponse.json({ sessionId: newSession[0].id })
return NextResponse.json({
sessionId: newSession[0].id,
viewerToken: newSession[0].viewerToken,
machineId: sessionCode.machineId,
})
}

View File

@@ -0,0 +1,62 @@
import { auth } from '@/auth'
import { db } from '@/lib/db'
import { enrollmentTokens } from '@/lib/db/schema'
import { eq, desc } from 'drizzle-orm'
import { NextRequest, NextResponse } from 'next/server'
// GET /api/enrollment-tokens — list all (admin only)
export async function GET() {
const session = await auth()
if (!session?.user || session.user.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const tokens = await db
.select()
.from(enrollmentTokens)
.orderBy(desc(enrollmentTokens.createdAt))
return NextResponse.json({ tokens })
}
// POST /api/enrollment-tokens — create new token (admin only)
export async function POST(request: NextRequest) {
const session = await auth()
if (!session?.user || session.user.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { label, expiresInDays, maxUses } = await request.json()
const expiresAt = expiresInDays
? new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000)
: null
const token = await db
.insert(enrollmentTokens)
.values({
label: label || null,
createdBy: session.user.id,
expiresAt,
maxUses: maxUses || null,
})
.returning()
return NextResponse.json({ token: token[0] }, { status: 201 })
}
// DELETE /api/enrollment-tokens — revoke token (admin only)
export async function DELETE(request: NextRequest) {
const session = await auth()
if (!session?.user || session.user.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { id } = await request.json()
await db
.update(enrollmentTokens)
.set({ revokedAt: new Date() })
.where(eq(enrollmentTokens.id, id))
return NextResponse.json({ success: true })
}

View File

@@ -39,7 +39,7 @@ export default function AuthErrorPage() {
</Link>
</Button>
<Button asChild className="flex-1">
<Link href="/auth/sign-up">
<Link href="/auth/login">
Try again
</Link>
</Button>

View File

@@ -20,15 +20,15 @@ export default function SignUpSuccessPage() {
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
<Mail className="h-8 w-8 text-primary" />
</div>
<CardTitle className="text-2xl">Check your email</CardTitle>
<CardTitle className="text-2xl">Account created</CardTitle>
<CardDescription className="text-balance">
{"We've sent you a confirmation link. Please check your inbox and click the link to activate your account."}
Your account has been set up. You can now sign in.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-lg bg-muted/50 p-4 text-sm text-muted-foreground">
<p className="text-balance">
{"Didn't receive the email? Check your spam folder or try signing up again with a different email address."}
Use the email address from your invite link to log in.
</p>
</div>
<Button asChild className="w-full">

View File

@@ -1,15 +1,9 @@
'use client'
import { useEffect, useState, useRef, useCallback } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { useParams, useRouter, useSearchParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import {
Maximize2,
Minimize2,
Monitor,
Loader2,
AlertCircle,
} from 'lucide-react'
import { Maximize2, Minimize2, Monitor, Loader2, AlertCircle, WifiOff } from 'lucide-react'
import { ViewerToolbar } from '@/components/viewer/toolbar'
import { ConnectionStatus } from '@/components/viewer/connection-status'
@@ -22,12 +16,15 @@ interface Session {
connectionType: string | null
}
type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'error'
type ConnectionState = 'connecting' | 'waiting' | 'connected' | 'disconnected' | 'error'
export default function ViewerPage() {
const params = useParams()
const searchParams = useSearchParams()
const router = useRouter()
const sessionId = params.sessionId as string
// viewerToken is passed as a query param from the connect flow
const viewerToken = searchParams.get('token')
const [session, setSession] = useState<Session | null>(null)
const [connectionState, setConnectionState] = useState<ConnectionState>('connecting')
@@ -36,10 +33,16 @@ export default function ViewerPage() {
const [quality, setQuality] = useState<'high' | 'medium' | 'low'>('high')
const [isMuted, setIsMuted] = useState(true)
const [error, setError] = useState<string | null>(null)
const [statusMsg, setStatusMsg] = useState('Connecting to relay…')
const [fps, setFps] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const wsRef = useRef<WebSocket | null>(null)
const fpsCounterRef = useRef({ frames: 0, lastTime: Date.now() })
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// ── Load session info ──────────────────────────────────────────────────────
useEffect(() => {
fetch(`/api/sessions/${sessionId}`)
.then((r) => r.json())
@@ -55,7 +58,6 @@ export default function ViewerPage() {
return
}
setSession(data.session)
simulateConnection()
})
.catch(() => {
setError('Failed to load session')
@@ -63,109 +65,171 @@ export default function ViewerPage() {
})
}, [sessionId])
const simulateConnection = () => {
// ── WebSocket connection ───────────────────────────────────────────────────
const connectWS = useCallback(() => {
if (!viewerToken) return
const relayHost = process.env.NEXT_PUBLIC_RELAY_URL ||
`${window.location.hostname}:8765`
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
const wsUrl = `${proto}://${relayHost}/ws/viewer?session_id=${sessionId}&viewer_token=${viewerToken}`
setConnectionState('connecting')
setTimeout(() => {
setConnectionState('connected')
startDemoScreen()
}, 2000)
}
setStatusMsg('Connecting to relay…')
const startDemoScreen = () => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const ws = new WebSocket(wsUrl)
ws.binaryType = 'arraybuffer'
wsRef.current = ws
canvas.width = 1920
canvas.height = 1080
const draw = () => {
ctx.fillStyle = '#1e1e2e'
ctx.fillRect(0, 0, canvas.width, canvas.height)
const icons = [
{ x: 50, y: 50, label: 'Documents' },
{ x: 50, y: 150, label: 'Pictures' },
{ x: 50, y: 250, label: 'Downloads' },
]
ctx.font = '14px system-ui'
ctx.textAlign = 'center'
icons.forEach((icon) => {
ctx.fillStyle = '#313244'
ctx.fillRect(icon.x, icon.y, 60, 60)
ctx.fillStyle = '#cdd6f4'
ctx.fillText(icon.label, icon.x + 30, icon.y + 80)
})
ctx.fillStyle = '#181825'
ctx.fillRect(0, canvas.height - 48, canvas.width, 48)
ctx.fillStyle = '#89b4fa'
ctx.fillRect(10, canvas.height - 40, 40, 32)
const time = new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
ctx.fillStyle = '#cdd6f4'
ctx.font = '14px system-ui'
ctx.textAlign = 'right'
ctx.fillText(time, canvas.width - 20, canvas.height - 18)
ctx.fillStyle = '#1e1e2e'
ctx.fillRect(300, 100, 800, 500)
ctx.strokeStyle = '#313244'
ctx.lineWidth = 1
ctx.strokeRect(300, 100, 800, 500)
ctx.fillStyle = '#181825'
ctx.fillRect(300, 100, 800, 32)
ctx.fillStyle = '#cdd6f4'
ctx.font = '13px system-ui'
ctx.textAlign = 'left'
ctx.fillText('RemoteLink Agent - Connected', 312, 121)
ctx.fillStyle = '#a6adc8'
ctx.font = '16px system-ui'
ctx.fillText('Remote session active', 320, 180)
ctx.fillText('Connection: Secure (WebRTC)', 320, 210)
ctx.fillText('Latency: ~45ms', 320, 240)
ctx.fillStyle = '#a6e3a1'
ctx.beginPath()
ctx.arc(320, 280, 6, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = '#cdd6f4'
ctx.fillText('Connected to viewer', 335, 285)
ws.onopen = () => {
setStatusMsg('Waiting for agent…')
setConnectionState('waiting')
}
draw()
const interval = setInterval(draw, 1000)
return () => clearInterval(interval)
ws.onmessage = (evt) => {
if (evt.data instanceof ArrayBuffer) {
// Binary = JPEG frame
renderFrame(evt.data)
} else {
try {
const msg = JSON.parse(evt.data as string)
handleRelayMessage(msg)
} catch { /* ignore */ }
}
}
ws.onclose = (evt) => {
wsRef.current = null
if (connectionState !== 'disconnected' && evt.code !== 4001) {
setConnectionState('waiting')
setStatusMsg('Connection lost — reconnecting…')
reconnectTimerRef.current = setTimeout(connectWS, 3000)
}
}
ws.onerror = () => {
setStatusMsg('Relay connection failed')
}
}, [sessionId, viewerToken])
useEffect(() => {
if (session && viewerToken) {
connectWS()
}
return () => {
wsRef.current?.close()
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current)
}
}, [session, viewerToken, connectWS])
const handleRelayMessage = (msg: Record<string, unknown>) => {
switch (msg.type) {
case 'agent_connected':
setConnectionState('connected')
setStatusMsg('')
break
case 'agent_offline':
setConnectionState('waiting')
setStatusMsg('Agent is offline — waiting for connection…')
break
case 'agent_disconnected':
setConnectionState('waiting')
setStatusMsg('Agent disconnected — waiting for reconnect…')
break
}
}
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (connectionState !== 'connected') return
console.log('[Viewer] Key pressed:', e.key)
},
[connectionState]
)
// ── Frame rendering ────────────────────────────────────────────────────────
const renderFrame = useCallback((data: ArrayBuffer) => {
const canvas = canvasRef.current
if (!canvas) return
const handleMouseMove = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
if (connectionState !== 'connected') return
const canvas = canvasRef.current
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const _x = ((e.clientX - rect.left) * canvas.width) / rect.width
const _y = ((e.clientY - rect.top) * canvas.height) / rect.height
},
[connectionState]
)
const blob = new Blob([data], { type: 'image/jpeg' })
const url = URL.createObjectURL(blob)
const img = new Image()
img.onload = () => {
const ctx = canvas.getContext('2d')
if (!ctx) return
if (canvas.width !== img.width || canvas.height !== img.height) {
canvas.width = img.width
canvas.height = img.height
}
ctx.drawImage(img, 0, 0)
URL.revokeObjectURL(url)
const handleMouseClick = useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
if (connectionState !== 'connected') return
console.log('[Viewer] Mouse clicked:', e.button)
},
[connectionState]
)
// FPS counter
const counter = fpsCounterRef.current
counter.frames++
const now = Date.now()
if (now - counter.lastTime >= 1000) {
setFps(counter.frames)
counter.frames = 0
counter.lastTime = now
}
}
img.src = url
}, [])
// ── Input forwarding ───────────────────────────────────────────────────────
const sendEvent = useCallback((event: Record<string, unknown>) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(event))
}
}, [])
const getCanvasCoords = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current!
const rect = canvas.getBoundingClientRect()
return {
x: Math.round(((e.clientX - rect.left) * canvas.width) / rect.width),
y: Math.round(((e.clientY - rect.top) * canvas.height) / rect.height),
}
}, [])
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (connectionState !== 'connected') return
const { x, y } = getCanvasCoords(e)
sendEvent({ type: 'mouse_move', x, y })
}, [connectionState, sendEvent, getCanvasCoords])
const handleMouseClick = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (connectionState !== 'connected') return
const { x, y } = getCanvasCoords(e)
sendEvent({
type: 'mouse_click',
button: e.button === 2 ? 'right' : 'left',
double: e.detail === 2,
x, y,
})
}, [connectionState, sendEvent, getCanvasCoords])
const handleMouseScroll = useCallback((e: React.WheelEvent<HTMLCanvasElement>) => {
if (connectionState !== 'connected') return
e.preventDefault()
sendEvent({ type: 'mouse_scroll', dx: e.deltaX, dy: -e.deltaY })
}, [connectionState, sendEvent])
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (connectionState !== 'connected') return
e.preventDefault()
const specialKeys = [
'Enter', 'Escape', 'Tab', 'Backspace', 'Delete', 'Home', 'End',
'PageUp', 'PageDown', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight',
'F1','F2','F3','F4','F5','F6','F7','F8','F9','F10','F11','F12',
]
if (specialKeys.includes(e.key)) {
sendEvent({ type: 'key_special', key: e.key.toLowerCase().replace('arrow', '') })
} else if (e.key.length === 1) {
sendEvent({ type: 'key_press', key: e.key })
}
}, [connectionState, sendEvent])
useEffect(() => {
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [handleKeyDown])
// ── Fullscreen / toolbar auto-hide ─────────────────────────────────────────
const toggleFullscreen = async () => {
if (!containerRef.current) return
if (!document.fullscreenElement) {
@@ -177,7 +241,16 @@ export default function ViewerPage() {
}
}
useEffect(() => {
if (isFullscreen && connectionState === 'connected') {
const timer = setTimeout(() => setShowToolbar(false), 3000)
return () => clearTimeout(timer)
}
}, [isFullscreen, connectionState, showToolbar])
// ── End session ────────────────────────────────────────────────────────────
const endSession = async () => {
wsRef.current?.close()
if (session) {
const duration = Math.floor((Date.now() - new Date(session.startedAt).getTime()) / 1000)
await fetch(`/api/sessions/${session.id}`, {
@@ -190,18 +263,7 @@ export default function ViewerPage() {
router.push('/dashboard/sessions')
}
useEffect(() => {
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [handleKeyDown])
useEffect(() => {
if (isFullscreen && connectionState === 'connected') {
const timer = setTimeout(() => setShowToolbar(false), 3000)
return () => clearTimeout(timer)
}
}, [isFullscreen, connectionState, showToolbar])
// ── Render ─────────────────────────────────────────────────────────────────
if (error) {
return (
<div className="min-h-svh bg-background flex items-center justify-center">
@@ -228,21 +290,26 @@ export default function ViewerPage() {
quality={quality}
isMuted={isMuted}
onToggleFullscreen={toggleFullscreen}
onQualityChange={setQuality}
onQualityChange={(q) => {
setQuality(q)
sendEvent({ type: 'set_quality', quality: q })
}}
onToggleMute={() => setIsMuted(!isMuted)}
onDisconnect={endSession}
onReconnect={simulateConnection}
onReconnect={connectWS}
/>
</div>
<div className="flex-1 flex items-center justify-center relative">
{connectionState === 'connecting' && (
{(connectionState === 'connecting' || connectionState === 'waiting') && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-10">
<div className="text-center space-y-4">
<Loader2 className="h-12 w-12 animate-spin text-primary mx-auto" />
{connectionState === 'waiting'
? <WifiOff className="h-12 w-12 text-muted-foreground mx-auto" />
: <Loader2 className="h-12 w-12 animate-spin text-primary mx-auto" />}
<div>
<p className="font-semibold">Connecting to remote machine...</p>
<p className="text-sm text-muted-foreground">{session?.machineName || 'Establishing connection'}</p>
<p className="font-semibold">{statusMsg}</p>
<p className="text-sm text-muted-foreground">{session?.machineName}</p>
</div>
</div>
</div>
@@ -250,13 +317,14 @@ export default function ViewerPage() {
<canvas
ref={canvasRef}
className="max-w-full max-h-full object-contain cursor-default"
className="max-w-full max-h-full object-contain cursor-crosshair"
style={{
display: connectionState === 'connected' ? 'block' : 'none',
imageRendering: quality === 'low' ? 'pixelated' : 'auto',
}}
onMouseMove={handleMouseMove}
onClick={handleMouseClick}
onWheel={handleMouseScroll}
onContextMenu={(e) => { e.preventDefault(); handleMouseClick(e) }}
/>
@@ -272,7 +340,7 @@ export default function ViewerPage() {
)}
</div>
{connectionState === 'connected' && <ConnectionStatus quality={quality} />}
{connectionState === 'connected' && <ConnectionStatus quality={quality} fps={fps} />}
</div>
)
}