Initial commit
This commit is contained in:
254
app/(dashboard)/dashboard/admin/page.tsx
Normal file
254
app/(dashboard)/dashboard/admin/page.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { UserPlus, Copy, Check, Trash2, Clock, CheckCircle2, Loader2 } from 'lucide-react'
|
||||
|
||||
interface Invite {
|
||||
id: string
|
||||
token: string
|
||||
email: string
|
||||
created_at: string
|
||||
expires_at: string
|
||||
used_at: string | null
|
||||
}
|
||||
|
||||
function inviteStatus(invite: Invite): 'used' | 'expired' | 'pending' {
|
||||
if (invite.used_at) return 'used'
|
||||
if (new Date(invite.expires_at) < new Date()) return 'expired'
|
||||
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() {
|
||||
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 fetchInvites = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/invites')
|
||||
const data = await res.json()
|
||||
if (res.ok) setInvites(data.invites)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchInvites()
|
||||
}, [fetchInvites])
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setSuccessEmail(null)
|
||||
setNewToken(null)
|
||||
setIsCreating(true)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/invites', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error)
|
||||
return
|
||||
}
|
||||
|
||||
setSuccessEmail(email)
|
||||
setNewToken(data.invite.token)
|
||||
setEmail('')
|
||||
await fetchInvites()
|
||||
} catch {
|
||||
setError('Network error. Please try again.')
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
const res = await fetch('/api/invites', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id }),
|
||||
})
|
||||
if (res.ok) await fetchInvites()
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
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 */}
|
||||
<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>
|
||||
</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"
|
||||
/>
|
||||
<Button type="submit" disabled={isCreating}>
|
||||
{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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
<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>
|
||||
<CopyButton text={inviteUrl(newToken)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Invites list */}
|
||||
<Card className="border-border/50">
|
||||
<CardHeader>
|
||||
<CardTitle>Invitations</CardTitle>
|
||||
<CardDescription>All invite links, newest first</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<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>
|
||||
) : (
|
||||
<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 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>
|
||||
</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)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
138
app/(dashboard)/dashboard/connect/page.tsx
Normal file
138
app/(dashboard)/dashboard/connect/page.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Link2, ArrowRight, Loader2, AlertCircle, Clock, Shield } from 'lucide-react'
|
||||
|
||||
export default function ConnectPage() {
|
||||
const [sessionCode, setSessionCode] = useState('')
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const router = useRouter()
|
||||
|
||||
const handleConnect = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsConnecting(true)
|
||||
setError(null)
|
||||
|
||||
const code = sessionCode.replace(/\s/g, '').toUpperCase()
|
||||
|
||||
if (code.length !== 6) {
|
||||
setError('Please enter a valid 6-character session code')
|
||||
setIsConnecting(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/connect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code }),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Invalid or expired session code')
|
||||
setIsConnecting(false)
|
||||
return
|
||||
}
|
||||
|
||||
router.push(`/viewer/${data.sessionId}`)
|
||||
} catch {
|
||||
setError('Network error. Please try again.')
|
||||
setIsConnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatCode = (value: string) => {
|
||||
const cleaned = value.replace(/[^A-Za-z0-9]/g, '').toUpperCase()
|
||||
if (cleaned.length <= 3) return cleaned
|
||||
return `${cleaned.slice(0, 3)} ${cleaned.slice(3, 6)}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-xl mx-auto space-y-6">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold">Quick Connect</h2>
|
||||
<p className="text-muted-foreground">Enter a session code to connect to a remote machine</p>
|
||||
</div>
|
||||
|
||||
<Card className="border-border/50">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
|
||||
<Link2 className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<CardTitle>Enter Session Code</CardTitle>
|
||||
<CardDescription className="text-balance">
|
||||
Ask the person on the remote machine to generate a code from their RemoteLink agent
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleConnect} className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="code" className="sr-only">Session Code</Label>
|
||||
<Input
|
||||
id="code"
|
||||
type="text"
|
||||
placeholder="ABC 123"
|
||||
value={sessionCode}
|
||||
onChange={(e) => setSessionCode(formatCode(e.target.value))}
|
||||
maxLength={7}
|
||||
className="text-center text-3xl font-mono tracking-widest h-16 bg-secondary/50"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-destructive/10 border border-destructive/20 p-3">
|
||||
<AlertCircle className="h-4 w-4 text-destructive shrink-0" />
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-12"
|
||||
disabled={isConnecting || sessionCode.replace(/\s/g, '').length < 6}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Connect
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="flex items-start gap-3 p-4 rounded-lg bg-card border border-border/50">
|
||||
<Clock className="h-5 w-5 text-primary mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">Codes expire</p>
|
||||
<p className="text-sm text-muted-foreground">Session codes are valid for 10 minutes</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 p-4 rounded-lg bg-card border border-border/50">
|
||||
<Shield className="h-5 w-5 text-primary mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">Secure connection</p>
|
||||
<p className="text-sm text-muted-foreground">All sessions are end-to-end encrypted</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
117
app/(dashboard)/dashboard/machines/page.tsx
Normal file
117
app/(dashboard)/dashboard/machines/page.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { auth } from '@/auth'
|
||||
import { db } from '@/lib/db'
|
||||
import { machines } from '@/lib/db/schema'
|
||||
import { eq, desc } from 'drizzle-orm'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import Link from 'next/link'
|
||||
import { Download, Laptop, Link2 } from 'lucide-react'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { MachineActions } from '@/components/dashboard/machine-actions'
|
||||
|
||||
export default async function MachinesPage() {
|
||||
const session = await auth()
|
||||
const machineList = await db
|
||||
.select()
|
||||
.from(machines)
|
||||
.where(eq(machines.userId, session!.user.id))
|
||||
.orderBy(desc(machines.isOnline), desc(machines.lastSeen))
|
||||
|
||||
const onlineCount = machineList.filter((m) => m.isOnline).length
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Machines</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{machineList.length} machine{machineList.length !== 1 ? 's' : ''} registered, {onlineCount} online
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/download">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download Agent
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{machineList.length > 0 ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{machineList.map((machine) => (
|
||||
<Card key={machine.id} className="border-border/50">
|
||||
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${machine.isOnline ? 'bg-green-500/10 text-green-500' : 'bg-muted text-muted-foreground'}`}>
|
||||
<Laptop className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">{machine.name}</CardTitle>
|
||||
<CardDescription className="text-xs">{machine.hostname || 'Unknown host'}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<MachineActions machine={machine} />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Status</span>
|
||||
<span className={`flex items-center gap-1.5 ${machine.isOnline ? 'text-green-500' : 'text-muted-foreground'}`}>
|
||||
<span className={`h-2 w-2 rounded-full ${machine.isOnline ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
|
||||
{machine.isOnline ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">OS</span>
|
||||
<span>{machine.os || 'Unknown'} {machine.osVersion || ''}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Last seen</span>
|
||||
<span>
|
||||
{machine.lastSeen
|
||||
? formatDistanceToNow(new Date(machine.lastSeen), { addSuffix: true })
|
||||
: 'Never'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Agent</span>
|
||||
<span className="font-mono text-xs">{machine.agentVersion || 'N/A'}</span>
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<Button className="w-full" size="sm" disabled={!machine.isOnline} asChild={machine.isOnline}>
|
||||
{machine.isOnline ? (
|
||||
<Link href={`/viewer/${machine.id}`}>
|
||||
<Link2 className="mr-2 h-4 w-4" />
|
||||
Connect
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<Link2 className="mr-2 h-4 w-4" />
|
||||
Connect
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="border-border/50">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||
<Laptop className="h-12 w-12 text-muted-foreground/30 mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">No machines yet</h3>
|
||||
<p className="text-muted-foreground text-center mb-6 max-w-sm text-balance">
|
||||
Download and install the RemoteLink agent on the machines you want to control remotely.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link href="/download">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download Agent
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
162
app/(dashboard)/dashboard/page.tsx
Normal file
162
app/(dashboard)/dashboard/page.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { auth } from '@/auth'
|
||||
import { db } from '@/lib/db'
|
||||
import { machines, sessions } from '@/lib/db/schema'
|
||||
import { eq, desc } from 'drizzle-orm'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Laptop,
|
||||
History,
|
||||
Link2,
|
||||
ArrowRight,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
Circle
|
||||
} from 'lucide-react'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await auth()
|
||||
const userId = session!.user.id
|
||||
|
||||
const [machineList, sessionList] = await Promise.all([
|
||||
db.select().from(machines).where(eq(machines.userId, userId)).orderBy(desc(machines.lastSeen)),
|
||||
db.select().from(sessions).where(eq(sessions.viewerUserId, userId)).orderBy(desc(sessions.startedAt)).limit(5),
|
||||
])
|
||||
|
||||
const onlineMachines = machineList.filter((m) => m.isOnline)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card className="border-border/50">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Total Machines</CardTitle>
|
||||
<Laptop className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{machineList.length}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{onlineMachines.length} online</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/50">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Recent Sessions</CardTitle>
|
||||
<History className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{sessionList.length}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">Last 5 sessions</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/50">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Quick Connect</CardTitle>
|
||||
<Link2 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild size="sm" className="w-full">
|
||||
<Link href="/dashboard/connect">
|
||||
Enter Code <ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card className="border-border/50">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Your Machines</CardTitle>
|
||||
<CardDescription>Registered remote machines</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/dashboard/machines">View all</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{machineList.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{machineList.slice(0, 5).map((machine) => (
|
||||
<div key={machine.id} className="flex items-center justify-between p-3 rounded-lg bg-muted/30">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`h-2 w-2 rounded-full ${machine.isOnline ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{machine.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{machine.os} {machine.osVersion}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{machine.lastSeen
|
||||
? formatDistanceToNow(new Date(machine.lastSeen), { addSuffix: true })
|
||||
: 'Never connected'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Laptop className="h-10 w-10 text-muted-foreground/30 mb-4" />
|
||||
<p className="text-sm text-muted-foreground mb-4">No machines registered yet</p>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/download">Download Agent</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/50">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Recent Sessions</CardTitle>
|
||||
<CardDescription>Your connection history</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/dashboard/sessions">View all</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{sessionList.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{sessionList.map((s) => (
|
||||
<div key={s.id} className="flex items-center justify-between p-3 rounded-lg bg-muted/30">
|
||||
<div className="flex items-center gap-3">
|
||||
{s.endedAt ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Circle className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-medium">{s.machineName || 'Unknown Machine'}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{s.connectionType === 'session_code' ? 'Session Code' : 'Direct'} connection
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatDistanceToNow(new Date(s.startedAt), { addSuffix: true })}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<History className="h-10 w-10 text-muted-foreground/30 mb-4" />
|
||||
<p className="text-sm text-muted-foreground mb-4">No sessions yet</p>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/dashboard/connect">Start connecting</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
106
app/(dashboard)/dashboard/sessions/page.tsx
Normal file
106
app/(dashboard)/dashboard/sessions/page.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { auth } from '@/auth'
|
||||
import { db } from '@/lib/db'
|
||||
import { sessions } from '@/lib/db/schema'
|
||||
import { eq, desc } from 'drizzle-orm'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { History, Clock, Monitor, CheckCircle2, Circle, Link2 } from 'lucide-react'
|
||||
import { format, formatDistanceToNow } from 'date-fns'
|
||||
|
||||
function formatDuration(seconds: number | null) {
|
||||
if (!seconds) return '-'
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
const s = seconds % 60
|
||||
if (h > 0) return `${h}h ${m}m`
|
||||
if (m > 0) return `${m}m ${s}s`
|
||||
return `${s}s`
|
||||
}
|
||||
|
||||
export default async function SessionsPage() {
|
||||
const session = await auth()
|
||||
const sessionList = await db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.viewerUserId, session!.user.id))
|
||||
.orderBy(desc(sessions.startedAt))
|
||||
.limit(50)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Session History</h2>
|
||||
<p className="text-muted-foreground">View your remote connection history</p>
|
||||
</div>
|
||||
|
||||
{sessionList.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{sessionList.map((s) => (
|
||||
<Card key={s.id} className="border-border/50">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${s.endedAt ? 'bg-muted text-muted-foreground' : 'bg-green-500/10 text-green-500'}`}>
|
||||
<Monitor className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium">{s.machineName || 'Unknown Machine'}</p>
|
||||
{s.endedAt
|
||||
? <CheckCircle2 className="h-4 w-4 text-muted-foreground" />
|
||||
: <Circle className="h-4 w-4 text-green-500" />}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Link2 className="h-3 w-3" />
|
||||
{s.connectionType === 'session_code' ? 'Session Code' : 'Direct'}
|
||||
</span>
|
||||
{s.sessionCode && (
|
||||
<span className="font-mono text-xs">{s.sessionCode}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 sm:gap-6 text-sm pl-14 sm:pl-0">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs">Started</p>
|
||||
<p className="font-medium">{format(new Date(s.startedAt), 'MMM d, h:mm a')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs">Duration</p>
|
||||
<p className="font-medium flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{s.endedAt ? formatDuration(s.durationSeconds) : 'Active'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs">Status</p>
|
||||
<p className={`font-medium ${s.endedAt ? 'text-muted-foreground' : 'text-green-500'}`}>
|
||||
{s.endedAt ? 'Completed' : 'Active'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{s.notes && (
|
||||
<div className="mt-3 pt-3 border-t border-border/50 pl-14">
|
||||
<p className="text-sm text-muted-foreground">{s.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="border-border/50">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||
<History className="h-12 w-12 text-muted-foreground/30 mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">No sessions yet</h3>
|
||||
<p className="text-muted-foreground text-center max-w-sm text-balance">
|
||||
Your remote session history will appear here once you start connecting to machines.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
138
app/(dashboard)/dashboard/settings/page.tsx
Normal file
138
app/(dashboard)/dashboard/settings/page.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { User, Save, Loader2 } from 'lucide-react'
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [fullName, setFullName] = useState('')
|
||||
const [company, setCompany] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [role, setRole] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/profile')
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.user) {
|
||||
setFullName(data.user.fullName || '')
|
||||
setCompany(data.user.company || '')
|
||||
setEmail(data.user.email || '')
|
||||
setRole(data.user.role || 'user')
|
||||
}
|
||||
})
|
||||
.finally(() => setIsLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSaving(true)
|
||||
setMessage(null)
|
||||
|
||||
const res = await fetch('/api/profile', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ fullName, company }),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: 'Profile updated successfully' })
|
||||
} else {
|
||||
setMessage({ type: 'error', text: data.error || 'Failed to save' })
|
||||
}
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Settings</h2>
|
||||
<p className="text-muted-foreground">Manage your account settings and preferences</p>
|
||||
</div>
|
||||
|
||||
<Card className="border-border/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
Profile
|
||||
</CardTitle>
|
||||
<CardDescription>Update your personal information</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSave} className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="fullName">Full name</Label>
|
||||
<Input
|
||||
id="fullName"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value)}
|
||||
placeholder="John Doe"
|
||||
className="bg-secondary/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="company">Company</Label>
|
||||
<Input
|
||||
id="company"
|
||||
value={company}
|
||||
onChange={(e) => setCompany(e.target.value)}
|
||||
placeholder="Acme Inc."
|
||||
className="bg-secondary/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`rounded-md p-3 text-sm ${message.type === 'success' ? 'bg-green-500/10 text-green-500 border border-green-500/20' : 'bg-destructive/10 text-destructive border border-destructive/20'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Saving...</>
|
||||
) : (
|
||||
<><Save className="mr-2 h-4 w-4" />Save changes</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/50">
|
||||
<CardHeader>
|
||||
<CardTitle>Account</CardTitle>
|
||||
<CardDescription>Your account details</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/30">
|
||||
<div>
|
||||
<p className="font-medium">Email</p>
|
||||
<p className="text-sm text-muted-foreground">{email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/30">
|
||||
<div>
|
||||
<p className="font-medium">Role</p>
|
||||
<p className="text-sm text-muted-foreground capitalize">{role}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
151
app/(dashboard)/download/page.tsx
Normal file
151
app/(dashboard)/download/page.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
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'
|
||||
|
||||
const platforms = [
|
||||
{
|
||||
name: 'Windows',
|
||||
icon: Monitor,
|
||||
description: 'Windows 10/11 (64-bit)',
|
||||
filename: 'RemoteLink-Setup.exe',
|
||||
size: '45 MB',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'macOS',
|
||||
icon: Apple,
|
||||
description: 'macOS 11+ (Apple Silicon & Intel)',
|
||||
filename: 'RemoteLink.dmg',
|
||||
size: '52 MB',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
name: 'Linux',
|
||||
icon: Terminal,
|
||||
description: 'Ubuntu, Debian, Fedora, Arch',
|
||||
filename: 'remotelink-agent.AppImage',
|
||||
size: '48 MB',
|
||||
available: true,
|
||||
},
|
||||
]
|
||||
|
||||
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',
|
||||
},
|
||||
]
|
||||
|
||||
export default function DownloadPage() {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{platforms.map((platform) => (
|
||||
<Card key={platform.name} className="border-border/50">
|
||||
<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>
|
||||
<CardTitle>{platform.name}</CardTitle>
|
||||
<CardDescription>{platform.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<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>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={!platform.available}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card className="border-border/50 bg-muted/20">
|
||||
<CardContent className="py-6">
|
||||
<h3 className="font-semibold mb-4 text-center">Agent Features</h3>
|
||||
<div className="grid gap-6 sm:grid-cols-3">
|
||||
{features.map((feature) => (
|
||||
<div key={feature.title} className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<feature.icon className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{feature.title}</p>
|
||||
<p className="text-sm text-muted-foreground">{feature.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-border/50">
|
||||
<CardHeader>
|
||||
<CardTitle>Installation Instructions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Windows</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>
|
||||
</ol>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">macOS</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>
|
||||
</ol>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Linux</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>
|
||||
</ol>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
app/(dashboard)/layout.tsx
Normal file
36
app/(dashboard)/layout.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { auth } from '@/auth'
|
||||
import { db } from '@/lib/db'
|
||||
import { users } from '@/lib/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { DashboardSidebar } from '@/components/dashboard/sidebar'
|
||||
import { DashboardHeader } from '@/components/dashboard/header'
|
||||
|
||||
export default async function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) redirect('/auth/login')
|
||||
|
||||
const result = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, session.user.id))
|
||||
.limit(1)
|
||||
|
||||
const user = result[0] ?? null
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh">
|
||||
<DashboardSidebar user={session.user} profile={user} />
|
||||
<div className="flex-1 flex flex-col">
|
||||
<DashboardHeader user={session.user} profile={user} />
|
||||
<main className="flex-1 p-6 bg-background overflow-auto">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
51
app/api/agent/heartbeat/route.ts
Normal file
51
app/api/agent/heartbeat/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { db } from '@/lib/db'
|
||||
import { machines, sessionCodes } from '@/lib/db/schema'
|
||||
import { eq, and, isNotNull, gt } from 'drizzle-orm'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { accessKey } = await request.json()
|
||||
|
||||
if (!accessKey) {
|
||||
return NextResponse.json({ error: 'Access key required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.update(machines)
|
||||
.set({ isOnline: true, lastSeen: new Date() })
|
||||
.where(eq(machines.accessKey, accessKey))
|
||||
.returning({ id: machines.id })
|
||||
|
||||
if (!result[0]) {
|
||||
return NextResponse.json({ error: 'Invalid access key' }, { status: 401 })
|
||||
}
|
||||
|
||||
const machineId = result[0].id
|
||||
|
||||
// Check for a pending connection (code used recently)
|
||||
const pending = await db
|
||||
.select()
|
||||
.from(sessionCodes)
|
||||
.where(
|
||||
and(
|
||||
eq(sessionCodes.machineId, machineId),
|
||||
eq(sessionCodes.isActive, true),
|
||||
gt(sessionCodes.expiresAt, new Date()),
|
||||
isNotNull(sessionCodes.usedAt)
|
||||
)
|
||||
)
|
||||
.orderBy(sessionCodes.usedAt)
|
||||
.limit(1)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
pendingConnection: pending[0]
|
||||
? { sessionCodeId: pending[0].id, usedBy: pending[0].usedBy }
|
||||
: null,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Heartbeat] Error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
46
app/api/agent/register/route.ts
Normal file
46
app/api/agent/register/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { db } from '@/lib/db'
|
||||
import { machines } from '@/lib/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { accessKey, name, hostname, os, osVersion, agentVersion, ipAddress } = body
|
||||
|
||||
if (!accessKey) {
|
||||
return NextResponse.json({ error: 'Access key required' }, { status: 400 })
|
||||
}
|
||||
|
||||
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({ success: true, machineId: machine.id })
|
||||
} catch (error) {
|
||||
console.error('[Agent Register] Error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
69
app/api/agent/session-code/route.ts
Normal file
69
app/api/agent/session-code/route.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { db } from '@/lib/db'
|
||||
import { machines, sessionCodes } from '@/lib/db/schema'
|
||||
import { eq, and } from 'drizzle-orm'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
function generateSessionCode(): string {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||
let code = ''
|
||||
for (let i = 0; i < 6; i++) {
|
||||
code += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { accessKey } = await request.json()
|
||||
|
||||
if (!accessKey) {
|
||||
return NextResponse.json({ error: 'Access key required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const machineResult = await db
|
||||
.select()
|
||||
.from(machines)
|
||||
.where(eq(machines.accessKey, accessKey))
|
||||
.limit(1)
|
||||
|
||||
const machine = machineResult[0]
|
||||
if (!machine) {
|
||||
return NextResponse.json({ error: 'Invalid access key' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Generate a unique code
|
||||
let code = ''
|
||||
for (let attempts = 0; attempts < 10; attempts++) {
|
||||
const candidate = generateSessionCode()
|
||||
const existing = await db
|
||||
.select({ id: sessionCodes.id })
|
||||
.from(sessionCodes)
|
||||
.where(and(eq(sessionCodes.code, candidate), eq(sessionCodes.isActive, true)))
|
||||
.limit(1)
|
||||
|
||||
if (!existing[0]) {
|
||||
code = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return NextResponse.json({ error: 'Failed to generate unique code' }, { status: 500 })
|
||||
}
|
||||
|
||||
const expiresAt = new Date(Date.now() + 10 * 60 * 1000)
|
||||
|
||||
await db.insert(sessionCodes).values({
|
||||
code,
|
||||
machineId: machine.id,
|
||||
createdBy: machine.userId,
|
||||
expiresAt,
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, code, expiresAt: expiresAt.toISOString(), expiresIn: 600 })
|
||||
} catch (error) {
|
||||
console.error('[Session Code] Error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
3
app/api/auth/[...nextauth]/route.ts
Normal file
3
app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { handlers } from '@/auth'
|
||||
|
||||
export const { GET, POST } = handlers
|
||||
67
app/api/connect/route.ts
Normal file
67
app/api/connect/route.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { auth } from '@/auth'
|
||||
import { db } from '@/lib/db'
|
||||
import { sessionCodes, sessions, machines } from '@/lib/db/schema'
|
||||
import { eq, and, isNull, gt } from 'drizzle-orm'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { code } = await request.json()
|
||||
if (!code || typeof code !== 'string') {
|
||||
return NextResponse.json({ error: 'Code required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const normalizedCode = code.replace(/\s/g, '').toUpperCase()
|
||||
|
||||
// Find valid, unused session code
|
||||
const codeResult = await db
|
||||
.select()
|
||||
.from(sessionCodes)
|
||||
.where(
|
||||
and(
|
||||
eq(sessionCodes.code, normalizedCode),
|
||||
eq(sessionCodes.isActive, true),
|
||||
gt(sessionCodes.expiresAt, new Date()),
|
||||
isNull(sessionCodes.usedAt)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
const sessionCode = codeResult[0]
|
||||
if (!sessionCode) {
|
||||
return NextResponse.json({ error: 'Invalid or expired session code' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Look up machine name
|
||||
const machineResult = await db
|
||||
.select({ name: machines.name })
|
||||
.from(machines)
|
||||
.where(eq(machines.id, sessionCode.machineId))
|
||||
.limit(1)
|
||||
|
||||
const machineName = machineResult[0]?.name ?? 'Remote Machine'
|
||||
|
||||
// Mark code as used
|
||||
await db
|
||||
.update(sessionCodes)
|
||||
.set({ usedAt: new Date(), usedBy: session.user.id, isActive: false })
|
||||
.where(eq(sessionCodes.id, sessionCode.id))
|
||||
|
||||
// Create session record
|
||||
const newSession = await db
|
||||
.insert(sessions)
|
||||
.values({
|
||||
machineId: sessionCode.machineId,
|
||||
machineName,
|
||||
viewerUserId: session.user.id,
|
||||
connectionType: 'session_code',
|
||||
sessionCode: normalizedCode,
|
||||
})
|
||||
.returning({ id: sessions.id })
|
||||
|
||||
return NextResponse.json({ sessionId: newSession[0].id })
|
||||
}
|
||||
72
app/api/invites/accept/route.ts
Normal file
72
app/api/invites/accept/route.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { db } from '@/lib/db'
|
||||
import { invites, users } from '@/lib/db/schema'
|
||||
import { eq, and, isNull, gt } from 'drizzle-orm'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const { token, fullName, password } = await request.json()
|
||||
|
||||
if (!token || !fullName || !password) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Password must be at least 8 characters' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate invite
|
||||
const inviteResult = await db
|
||||
.select()
|
||||
.from(invites)
|
||||
.where(eq(invites.token, token))
|
||||
.limit(1)
|
||||
|
||||
const invite = inviteResult[0]
|
||||
|
||||
if (!invite) {
|
||||
return NextResponse.json({ error: 'Invalid invite link' }, { status: 400 })
|
||||
}
|
||||
if (invite.usedAt) {
|
||||
return NextResponse.json({ error: 'This invite has already been used' }, { status: 400 })
|
||||
}
|
||||
if (new Date(invite.expiresAt) < new Date()) {
|
||||
return NextResponse.json({ error: 'This invite has expired' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check if user with this email already exists
|
||||
const existingUser = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.email, invite.email))
|
||||
.limit(1)
|
||||
|
||||
if (existingUser.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'An account with this email already exists' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 12)
|
||||
|
||||
const newUser = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
email: invite.email,
|
||||
passwordHash,
|
||||
fullName: fullName.trim(),
|
||||
})
|
||||
.returning({ id: users.id })
|
||||
|
||||
// Mark invite as used
|
||||
await db
|
||||
.update(invites)
|
||||
.set({ usedAt: new Date(), usedBy: newUser[0].id })
|
||||
.where(eq(invites.token, token))
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
76
app/api/invites/route.ts
Normal file
76
app/api/invites/route.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { auth } from '@/auth'
|
||||
import { db } from '@/lib/db'
|
||||
import { invites, users } from '@/lib/db/schema'
|
||||
import { eq, and, isNull, gt } from 'drizzle-orm'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
async function requireAdmin() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return null
|
||||
const role = (session.user as { role: string }).role
|
||||
if (role !== 'admin') return null
|
||||
return session.user
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const admin = await requireAdmin()
|
||||
if (!admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const { email } = await request.json()
|
||||
if (!email || typeof email !== 'string') {
|
||||
return NextResponse.json({ error: 'Valid email required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const normalizedEmail = email.toLowerCase().trim()
|
||||
|
||||
// Check for existing pending invite
|
||||
const existing = await db
|
||||
.select({ id: invites.id })
|
||||
.from(invites)
|
||||
.where(
|
||||
and(
|
||||
eq(invites.email, normalizedEmail),
|
||||
isNull(invites.usedAt),
|
||||
gt(invites.expiresAt, new Date())
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existing.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'A pending invite already exists for this email' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await db
|
||||
.insert(invites)
|
||||
.values({ email: normalizedEmail, createdBy: admin.id })
|
||||
.returning()
|
||||
|
||||
return NextResponse.json({ invite: result[0] }, { status: 201 })
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const admin = await requireAdmin()
|
||||
if (!admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const result = await db
|
||||
.select()
|
||||
.from(invites)
|
||||
.orderBy(invites.createdAt)
|
||||
|
||||
return NextResponse.json({ invites: result.reverse() })
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const admin = await requireAdmin()
|
||||
if (!admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
|
||||
const { id } = await request.json()
|
||||
if (!id) return NextResponse.json({ error: 'Invite ID required' }, { status: 400 })
|
||||
|
||||
await db.delete(invites).where(eq(invites.id, id))
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
29
app/api/machines/[id]/route.ts
Normal file
29
app/api/machines/[id]/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { auth } from '@/auth'
|
||||
import { db } from '@/lib/db'
|
||||
import { machines } from '@/lib/db/schema'
|
||||
import { eq, and } from 'drizzle-orm'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
// Only delete if the machine belongs to the requesting user
|
||||
const result = await db
|
||||
.delete(machines)
|
||||
.where(and(eq(machines.id, id), eq(machines.userId, session.user.id)))
|
||||
.returning({ id: machines.id })
|
||||
|
||||
if (!result[0]) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
32
app/api/profile/route.ts
Normal file
32
app/api/profile/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { auth } from '@/auth'
|
||||
import { db } from '@/lib/db'
|
||||
import { users } from '@/lib/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const result = await db
|
||||
.select({ fullName: users.fullName, company: users.company, email: users.email, role: users.role })
|
||||
.from(users)
|
||||
.where(eq(users.id, session.user.id))
|
||||
.limit(1)
|
||||
|
||||
return NextResponse.json({ user: result[0] ?? null })
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const { fullName, company } = await request.json()
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({ fullName: fullName ?? null, company: company ?? null, updatedAt: new Date() })
|
||||
.where(eq(users.id, session.user.id))
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
53
app/api/sessions/[id]/route.ts
Normal file
53
app/api/sessions/[id]/route.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { auth } from '@/auth'
|
||||
import { db } from '@/lib/db'
|
||||
import { sessions } from '@/lib/db/schema'
|
||||
import { eq, and } from 'drizzle-orm'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const result = await db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.id, id))
|
||||
.limit(1)
|
||||
|
||||
if (!result[0]) {
|
||||
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ session: result[0] })
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
|
||||
const updates: Record<string, unknown> = {}
|
||||
if (body.endedAt !== undefined) updates.endedAt = body.endedAt ? new Date(body.endedAt) : new Date()
|
||||
if (body.durationSeconds !== undefined) updates.durationSeconds = body.durationSeconds
|
||||
|
||||
await db
|
||||
.update(sessions)
|
||||
.set(updates)
|
||||
.where(and(eq(sessions.id, id), eq(sessions.viewerUserId, session.user.id)))
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
89
app/api/signal/route.ts
Normal file
89
app/api/signal/route.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { auth } from '@/auth'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
// In-memory signaling store (use Redis for multi-instance deployments)
|
||||
const signalingStore = new Map<string, {
|
||||
offer?: RTCSessionDescriptionInit
|
||||
answer?: RTCSessionDescriptionInit
|
||||
viewerCandidates: RTCIceCandidateInit[]
|
||||
hostCandidates: RTCIceCandidateInit[]
|
||||
createdAt: number
|
||||
}>()
|
||||
|
||||
function cleanupOldSessions() {
|
||||
const now = Date.now()
|
||||
for (const [key, value] of signalingStore.entries()) {
|
||||
if (now - value.createdAt > 5 * 60 * 1000) signalingStore.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { action, sessionId, data } = await request.json()
|
||||
cleanupOldSessions()
|
||||
|
||||
switch (action) {
|
||||
case 'create-session':
|
||||
signalingStore.set(sessionId, { viewerCandidates: [], hostCandidates: [], createdAt: Date.now() })
|
||||
return NextResponse.json({ success: true })
|
||||
|
||||
case 'send-offer': {
|
||||
const s = signalingStore.get(sessionId)
|
||||
if (!s) return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
||||
s.offer = data.offer
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
||||
case 'get-offer': {
|
||||
const s = signalingStore.get(sessionId)
|
||||
if (!s) return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
||||
return NextResponse.json({ offer: s.offer ?? null })
|
||||
}
|
||||
|
||||
case 'send-answer': {
|
||||
const s = signalingStore.get(sessionId)
|
||||
if (!s) return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
||||
s.answer = data.answer
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
||||
case 'get-answer': {
|
||||
const s = signalingStore.get(sessionId)
|
||||
if (!s) return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
||||
return NextResponse.json({ answer: s.answer ?? null })
|
||||
}
|
||||
|
||||
case 'send-ice-candidate': {
|
||||
const s = signalingStore.get(sessionId)
|
||||
if (!s) return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
||||
if (data.role === 'viewer') s.viewerCandidates.push(data.candidate)
|
||||
else s.hostCandidates.push(data.candidate)
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
||||
case 'get-ice-candidates': {
|
||||
const s = signalingStore.get(sessionId)
|
||||
if (!s) return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
||||
const candidates = data.role === 'viewer' ? s.hostCandidates : s.viewerCandidates
|
||||
if (data.role === 'viewer') s.hostCandidates = []
|
||||
else s.viewerCandidates = []
|
||||
return NextResponse.json({ candidates })
|
||||
}
|
||||
|
||||
case 'close-session':
|
||||
signalingStore.delete(sessionId)
|
||||
return NextResponse.json({ success: true })
|
||||
|
||||
default:
|
||||
return NextResponse.json({ error: 'Unknown action' }, { status: 400 })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Signal] Error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
53
app/auth/error/page.tsx
Normal file
53
app/auth/error/page.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import Link from 'next/link'
|
||||
import { Monitor, AlertTriangle, ArrowLeft } from 'lucide-react'
|
||||
|
||||
export default function AuthErrorPage() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary">
|
||||
<Monitor className="h-5 w-5 text-primary-foreground" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold">RemoteLink</span>
|
||||
</div>
|
||||
|
||||
<Card className="w-full border-border/50">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertTriangle className="h-8 w-8 text-destructive" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Authentication Error</CardTitle>
|
||||
<CardDescription className="text-balance">
|
||||
Something went wrong during authentication. This could be due to an expired link or an invalid request.
|
||||
</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">
|
||||
If you continue to experience issues, please contact support or try again later.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button asChild variant="outline" className="flex-1">
|
||||
<Link href="/auth/login">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to login
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild className="flex-1">
|
||||
<Link href="/auth/sign-up">
|
||||
Try again
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
136
app/auth/invite/[token]/invite-form.tsx
Normal file
136
app/auth/invite/[token]/invite-form.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
interface InviteFormProps {
|
||||
token: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export default function InviteForm({ token, email }: InviteFormProps) {
|
||||
const [fullName, setFullName] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
setError('Password must be at least 8 characters')
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/invites/accept', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, fullName, password }),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error || 'Something went wrong')
|
||||
return
|
||||
}
|
||||
|
||||
router.push('/auth/login?invited=1')
|
||||
} catch {
|
||||
setError('Network error. Please try again.')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-border/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Set up your account</CardTitle>
|
||||
<CardDescription>
|
||||
You were invited to join RemoteLink
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
disabled
|
||||
className="bg-secondary/50 opacity-60"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="fullName">Full name</Label>
|
||||
<Input
|
||||
id="fullName"
|
||||
type="text"
|
||||
placeholder="John Doe"
|
||||
required
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value)}
|
||||
className="bg-secondary/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="At least 8 characters"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="bg-secondary/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="confirmPassword">Confirm password</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="bg-secondary/50"
|
||||
/>
|
||||
</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" className="w-full mt-2" disabled={isLoading}>
|
||||
{isLoading ? 'Creating account...' : 'Create account'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
100
app/auth/invite/[token]/page.tsx
Normal file
100
app/auth/invite/[token]/page.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { db } from '@/lib/db'
|
||||
import { invites } from '@/lib/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import InviteForm from './invite-form'
|
||||
import { Monitor, XCircle } from 'lucide-react'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface InvitePageProps {
|
||||
params: Promise<{ token: string }>
|
||||
}
|
||||
|
||||
export default async function InvitePage({ params }: InvitePageProps) {
|
||||
const { token } = await params
|
||||
|
||||
const result = await db
|
||||
.select()
|
||||
.from(invites)
|
||||
.where(eq(invites.token, token))
|
||||
.limit(1)
|
||||
|
||||
const invite = result[0]
|
||||
const isExpired = invite && new Date(invite.expiresAt) < new Date()
|
||||
const isUsed = !!invite?.usedAt
|
||||
const isValid = invite && !isExpired && !isUsed
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh w-full">
|
||||
<div className="hidden lg:flex lg:w-1/2 bg-primary/5 items-center justify-center p-12">
|
||||
<div className="max-w-md">
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary">
|
||||
<Monitor className="h-6 w-6 text-primary-foreground" />
|
||||
</div>
|
||||
<span className="text-3xl font-bold">RemoteLink</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold mb-4 text-balance">
|
||||
{isValid ? "You've been invited" : 'RemoteLink'}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-balance">
|
||||
{isValid
|
||||
? 'Set up your account to start managing remote machines securely.'
|
||||
: 'Secure, low-latency remote desktop access for IT professionals.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full lg:w-1/2 items-center justify-center p-6 md:p-10">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-center gap-2 lg:hidden mb-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary">
|
||||
<Monitor className="h-5 w-5 text-primary-foreground" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold">RemoteLink</span>
|
||||
</div>
|
||||
|
||||
{isValid ? (
|
||||
<InviteForm token={token} email={invite.email} />
|
||||
) : (
|
||||
<Card className="border-border/50">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">
|
||||
<XCircle className="h-8 w-8 text-destructive" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">
|
||||
{!invite
|
||||
? 'Invalid invite'
|
||||
: isUsed
|
||||
? 'Already used'
|
||||
: 'Invite expired'}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-balance">
|
||||
{!invite
|
||||
? 'This invite link is invalid or does not exist.'
|
||||
: isUsed
|
||||
? 'This invite link has already been used to create an account.'
|
||||
: 'This invite link has expired. Please contact your administrator for a new one.'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild className="w-full">
|
||||
<Link href="/auth/login">Go to login</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
153
app/auth/login/page.tsx
Normal file
153
app/auth/login/page.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
'use client'
|
||||
|
||||
import { signIn } from 'next-auth/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useState, Suspense } from 'react'
|
||||
import { Monitor, Shield } from 'lucide-react'
|
||||
|
||||
function LoginForm() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const invited = searchParams.get('invited')
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
const result = await signIn('credentials', {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
})
|
||||
|
||||
if (result?.error) {
|
||||
setError('Invalid email or password')
|
||||
setIsLoading(false)
|
||||
} else {
|
||||
router.push('/dashboard')
|
||||
router.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-border/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">Welcome back</CardTitle>
|
||||
<CardDescription>
|
||||
Sign in to access your remote machines
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{invited && (
|
||||
<div className="mb-4 rounded-md bg-green-500/10 border border-green-500/20 p-3">
|
||||
<p className="text-sm text-green-500">
|
||||
Account created! You can now sign in.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleLogin}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@company.com"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="bg-secondary/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="bg-secondary/50"
|
||||
/>
|
||||
</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" className="w-full mt-2" disabled={isLoading}>
|
||||
{isLoading ? 'Signing in...' : 'Sign in'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-6 text-center text-sm text-muted-foreground">
|
||||
Access is by invitation only
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full">
|
||||
<div className="hidden lg:flex lg:w-1/2 bg-primary/5 items-center justify-center p-12">
|
||||
<div className="max-w-md text-center">
|
||||
<div className="flex items-center justify-center gap-3 mb-8">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary">
|
||||
<Monitor className="h-6 w-6 text-primary-foreground" />
|
||||
</div>
|
||||
<span className="text-3xl font-bold">RemoteLink</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold mb-4 text-balance">
|
||||
Professional Remote Desktop Solution
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-balance">
|
||||
Securely connect to and control remote machines. Perfect for IT support,
|
||||
system administration, and remote assistance.
|
||||
</p>
|
||||
<div className="mt-8 flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Shield className="h-4 w-4" />
|
||||
<span>End-to-end encrypted connections</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full lg:w-1/2 items-center justify-center p-6 md:p-10">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-center gap-2 lg:hidden mb-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary">
|
||||
<Monitor className="h-5 w-5 text-primary-foreground" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold">RemoteLink</span>
|
||||
</div>
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Self-hosted remote desktop. End-to-end encrypted.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
46
app/auth/sign-up-success/page.tsx
Normal file
46
app/auth/sign-up-success/page.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import Link from 'next/link'
|
||||
import { Monitor, Mail, ArrowRight } from 'lucide-react'
|
||||
|
||||
export default function SignUpSuccessPage() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary">
|
||||
<Monitor className="h-5 w-5 text-primary-foreground" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold">RemoteLink</span>
|
||||
</div>
|
||||
|
||||
<Card className="w-full border-border/50">
|
||||
<CardHeader className="text-center">
|
||||
<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>
|
||||
<CardDescription className="text-balance">
|
||||
{"We've sent you a confirmation link. Please check your inbox and click the link to activate your account."}
|
||||
</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."}
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild className="w-full">
|
||||
<Link href="/auth/login">
|
||||
Go to login
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
41
app/auth/sign-up/page.tsx
Normal file
41
app/auth/sign-up/page.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import Link from 'next/link'
|
||||
import { Monitor, Lock, ArrowRight } from 'lucide-react'
|
||||
|
||||
export default function SignUpPage() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary">
|
||||
<Monitor className="h-5 w-5 text-primary-foreground" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold">RemoteLink</span>
|
||||
</div>
|
||||
|
||||
<Card className="w-full border-border/50">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
|
||||
<Lock className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Invite only</CardTitle>
|
||||
<CardDescription className="text-balance">
|
||||
RemoteLink is invite-only. To get access, contact your administrator for an invite link.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild className="w-full">
|
||||
<Link href="/auth/login">
|
||||
Sign in
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
166
app/globals.css
Normal file
166
app/globals.css
Normal file
@@ -0,0 +1,166 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--background: oklch(0.985 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.55 0.2 250);
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.55 0.2 250);
|
||||
--chart-1: oklch(0.55 0.2 250);
|
||||
--chart-2: oklch(0.65 0.15 160);
|
||||
--chart-3: oklch(0.7 0.12 80);
|
||||
--chart-4: oklch(0.6 0.18 30);
|
||||
--chart-5: oklch(0.5 0.2 300);
|
||||
--radius: 0.5rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.55 0.2 250);
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.55 0.2 250);
|
||||
|
||||
/* Custom app colors */
|
||||
--success: oklch(0.65 0.2 145);
|
||||
--success-foreground: oklch(1 0 0);
|
||||
--warning: oklch(0.75 0.15 80);
|
||||
--warning-foreground: oklch(0.2 0 0);
|
||||
--online: oklch(0.65 0.2 145);
|
||||
--offline: oklch(0.5 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.12 0.01 260);
|
||||
--foreground: oklch(0.95 0 0);
|
||||
--card: oklch(0.16 0.01 260);
|
||||
--card-foreground: oklch(0.95 0 0);
|
||||
--popover: oklch(0.16 0.01 260);
|
||||
--popover-foreground: oklch(0.95 0 0);
|
||||
--primary: oklch(0.65 0.2 250);
|
||||
--primary-foreground: oklch(0.12 0 0);
|
||||
--secondary: oklch(0.22 0.01 260);
|
||||
--secondary-foreground: oklch(0.95 0 0);
|
||||
--muted: oklch(0.22 0.01 260);
|
||||
--muted-foreground: oklch(0.65 0 0);
|
||||
--accent: oklch(0.25 0.02 260);
|
||||
--accent-foreground: oklch(0.95 0 0);
|
||||
--destructive: oklch(0.55 0.22 25);
|
||||
--destructive-foreground: oklch(0.95 0 0);
|
||||
--border: oklch(0.25 0.01 260);
|
||||
--input: oklch(0.22 0.01 260);
|
||||
--ring: oklch(0.65 0.2 250);
|
||||
--chart-1: oklch(0.65 0.2 250);
|
||||
--chart-2: oklch(0.7 0.15 160);
|
||||
--chart-3: oklch(0.75 0.12 80);
|
||||
--chart-4: oklch(0.65 0.18 30);
|
||||
--chart-5: oklch(0.6 0.2 300);
|
||||
--sidebar: oklch(0.1 0.01 260);
|
||||
--sidebar-foreground: oklch(0.95 0 0);
|
||||
--sidebar-primary: oklch(0.65 0.2 250);
|
||||
--sidebar-primary-foreground: oklch(0.12 0 0);
|
||||
--sidebar-accent: oklch(0.22 0.01 260);
|
||||
--sidebar-accent-foreground: oklch(0.95 0 0);
|
||||
--sidebar-border: oklch(0.25 0.01 260);
|
||||
--sidebar-ring: oklch(0.65 0.2 250);
|
||||
|
||||
/* Custom app colors */
|
||||
--success: oklch(0.7 0.2 145);
|
||||
--success-foreground: oklch(0.12 0 0);
|
||||
--warning: oklch(0.8 0.15 80);
|
||||
--warning-foreground: oklch(0.12 0 0);
|
||||
--online: oklch(0.7 0.2 145);
|
||||
--offline: oklch(0.45 0 0);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: 'Inter', 'Inter Fallback', system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'JetBrains Mono Fallback', monospace;
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-success: var(--success);
|
||||
--color-success-foreground: var(--success-foreground);
|
||||
--color-warning: var(--warning);
|
||||
--color-warning-foreground: var(--warning-foreground);
|
||||
--color-online: var(--online);
|
||||
--color-offline: var(--offline);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar for dark theme */
|
||||
.dark ::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-track {
|
||||
background: oklch(0.15 0.01 260);
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.3 0.01 260);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(0.4 0.01 260);
|
||||
}
|
||||
50
app/layout.tsx
Normal file
50
app/layout.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import { Inter, JetBrains_Mono } from 'next/font/google'
|
||||
import { SessionProvider } from 'next-auth/react'
|
||||
import './globals.css'
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
const jetbrainsMono = JetBrains_Mono({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'RemoteLink - Remote Desktop Application',
|
||||
description: 'Professional remote desktop solution for IT support teams. Connect, control, and manage remote machines securely.',
|
||||
generator: 'v0.app',
|
||||
icons: {
|
||||
icon: [
|
||||
{
|
||||
url: '/icon-light-32x32.png',
|
||||
media: '(prefers-color-scheme: light)',
|
||||
},
|
||||
{
|
||||
url: '/icon-dark-32x32.png',
|
||||
media: '(prefers-color-scheme: dark)',
|
||||
},
|
||||
{
|
||||
url: '/icon.svg',
|
||||
type: 'image/svg+xml',
|
||||
},
|
||||
],
|
||||
apple: '/apple-icon.png',
|
||||
},
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: '#0a0a0f',
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" className="dark">
|
||||
<body className="font-sans antialiased">
|
||||
<SessionProvider>{children}</SessionProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
212
app/page.tsx
Normal file
212
app/page.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Monitor,
|
||||
Shield,
|
||||
Zap,
|
||||
Users,
|
||||
Download,
|
||||
ArrowRight,
|
||||
Keyboard,
|
||||
Globe,
|
||||
Clock
|
||||
} from 'lucide-react'
|
||||
|
||||
export default function HomePage() {
|
||||
const features = [
|
||||
{
|
||||
icon: Zap,
|
||||
title: 'Low Latency',
|
||||
description: 'WebRTC-powered connections for real-time screen sharing and control with minimal delay.',
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: 'Secure by Design',
|
||||
description: 'End-to-end encryption with session codes that expire. Your data never touches our servers.',
|
||||
},
|
||||
{
|
||||
icon: Keyboard,
|
||||
title: 'Full Control',
|
||||
description: 'Complete mouse and keyboard control. Multiple monitor support and file transfer.',
|
||||
},
|
||||
{
|
||||
icon: Globe,
|
||||
title: 'Works Everywhere',
|
||||
description: 'Connect from any browser. The agent runs on Windows, macOS, and Linux.',
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: 'Team Ready',
|
||||
description: 'Manage multiple machines, track session history, and collaborate with your team.',
|
||||
},
|
||||
{
|
||||
icon: Clock,
|
||||
title: 'Session History',
|
||||
description: 'Complete audit trail of all remote sessions with timestamps and duration.',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="min-h-svh bg-background">
|
||||
{/* Navigation */}
|
||||
<nav className="border-b border-border/50">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex h-16 items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary">
|
||||
<Monitor className="h-5 w-5 text-primary-foreground" />
|
||||
</div>
|
||||
<span className="text-xl font-bold">RemoteLink</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button asChild>
|
||||
<Link href="/auth/login">Sign in</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative overflow-hidden">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-24 md:py-32">
|
||||
<div className="text-center max-w-3xl mx-auto">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-primary/10 px-4 py-1.5 text-sm text-primary mb-6">
|
||||
<Shield className="h-4 w-4" />
|
||||
<span>Secure Remote Access</span>
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight text-balance mb-6">
|
||||
Remote Desktop
|
||||
<span className="text-primary"> Made Simple</span>
|
||||
</h1>
|
||||
<p className="text-lg md:text-xl text-muted-foreground max-w-2xl mx-auto mb-8 text-balance">
|
||||
Connect to and control remote machines securely. Perfect for IT support,
|
||||
system administration, and helping friends and family.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<Button size="lg" asChild>
|
||||
<Link href="/auth/login">
|
||||
Sign in
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size="lg" variant="outline" asChild>
|
||||
<Link href="/download">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download Agent
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background decoration */}
|
||||
<div className="absolute inset-0 -z-10 overflow-hidden">
|
||||
<div className="absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2 h-[600px] w-[600px] rounded-full bg-primary/5 blur-3xl" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How it works */}
|
||||
<section className="border-t border-border/50 bg-card/50">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-24">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl font-bold mb-4">How It Works</h2>
|
||||
<p className="text-muted-foreground max-w-xl mx-auto text-balance">
|
||||
Get connected in three simple steps
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
<div className="text-center">
|
||||
<div className="inline-flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-foreground text-xl font-bold mb-4">
|
||||
1
|
||||
</div>
|
||||
<h3 className="font-semibold mb-2">Install the Agent</h3>
|
||||
<p className="text-sm text-muted-foreground text-balance">
|
||||
Download and run our lightweight agent on the remote machine
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="inline-flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-foreground text-xl font-bold mb-4">
|
||||
2
|
||||
</div>
|
||||
<h3 className="font-semibold mb-2">Get a Session Code</h3>
|
||||
<p className="text-sm text-muted-foreground text-balance">
|
||||
The agent generates a 6-digit code that expires in 10 minutes
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="inline-flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-foreground text-xl font-bold mb-4">
|
||||
3
|
||||
</div>
|
||||
<h3 className="font-semibold mb-2">Connect & Control</h3>
|
||||
<p className="text-sm text-muted-foreground text-balance">
|
||||
Enter the code in your dashboard and take full control
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Grid */}
|
||||
<section className="border-t border-border/50">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-24">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl font-bold mb-4">Built for IT Professionals</h2>
|
||||
<p className="text-muted-foreground max-w-xl mx-auto text-balance">
|
||||
Everything you need for efficient remote support
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{features.map((feature, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-xl border border-border/50 bg-card p-6 hover:border-primary/30 transition-colors"
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 mb-4">
|
||||
<feature.icon className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<h3 className="font-semibold mb-2">{feature.title}</h3>
|
||||
<p className="text-sm text-muted-foreground text-balance">{feature.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="border-t border-border/50 bg-primary/5">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-24">
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl font-bold mb-4">Ready to connect?</h2>
|
||||
<p className="text-muted-foreground max-w-xl mx-auto mb-8 text-balance">
|
||||
Sign in to your account or contact your administrator for an invite.
|
||||
</p>
|
||||
<Button size="lg" asChild>
|
||||
<Link href="/auth/login">
|
||||
Sign in
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-border/50">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary">
|
||||
<Monitor className="h-4 w-4 text-primary-foreground" />
|
||||
</div>
|
||||
<span className="font-semibold">RemoteLink</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Built with WebRTC. End-to-end encrypted.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
278
app/viewer/[sessionId]/page.tsx
Normal file
278
app/viewer/[sessionId]/page.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Monitor,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
import { ViewerToolbar } from '@/components/viewer/toolbar'
|
||||
import { ConnectionStatus } from '@/components/viewer/connection-status'
|
||||
|
||||
interface Session {
|
||||
id: string
|
||||
machineId: string | null
|
||||
machineName: string | null
|
||||
startedAt: string
|
||||
endedAt: string | null
|
||||
connectionType: string | null
|
||||
}
|
||||
|
||||
type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'error'
|
||||
|
||||
export default function ViewerPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const sessionId = params.sessionId as string
|
||||
|
||||
const [session, setSession] = useState<Session | null>(null)
|
||||
const [connectionState, setConnectionState] = useState<ConnectionState>('connecting')
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const [showToolbar, setShowToolbar] = useState(true)
|
||||
const [quality, setQuality] = useState<'high' | 'medium' | 'low'>('high')
|
||||
const [isMuted, setIsMuted] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/sessions/${sessionId}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (!data.session) {
|
||||
setError('Session not found')
|
||||
setConnectionState('error')
|
||||
return
|
||||
}
|
||||
if (data.session.endedAt) {
|
||||
setError('This session has ended')
|
||||
setConnectionState('disconnected')
|
||||
return
|
||||
}
|
||||
setSession(data.session)
|
||||
simulateConnection()
|
||||
})
|
||||
.catch(() => {
|
||||
setError('Failed to load session')
|
||||
setConnectionState('error')
|
||||
})
|
||||
}, [sessionId])
|
||||
|
||||
const simulateConnection = () => {
|
||||
setConnectionState('connecting')
|
||||
setTimeout(() => {
|
||||
setConnectionState('connected')
|
||||
startDemoScreen()
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
const startDemoScreen = () => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
draw()
|
||||
const interval = setInterval(draw, 1000)
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (connectionState !== 'connected') return
|
||||
console.log('[Viewer] Key pressed:', e.key)
|
||||
},
|
||||
[connectionState]
|
||||
)
|
||||
|
||||
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 handleMouseClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (connectionState !== 'connected') return
|
||||
console.log('[Viewer] Mouse clicked:', e.button)
|
||||
},
|
||||
[connectionState]
|
||||
)
|
||||
|
||||
const toggleFullscreen = async () => {
|
||||
if (!containerRef.current) return
|
||||
if (!document.fullscreenElement) {
|
||||
await containerRef.current.requestFullscreen()
|
||||
setIsFullscreen(true)
|
||||
} else {
|
||||
await document.exitFullscreen()
|
||||
setIsFullscreen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const endSession = async () => {
|
||||
if (session) {
|
||||
const duration = Math.floor((Date.now() - new Date(session.startedAt).getTime()) / 1000)
|
||||
await fetch(`/api/sessions/${session.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ endedAt: new Date().toISOString(), durationSeconds: duration }),
|
||||
})
|
||||
}
|
||||
setConnectionState('disconnected')
|
||||
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])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-svh bg-background flex items-center justify-center">
|
||||
<div className="text-center space-y-4">
|
||||
<AlertCircle className="h-12 w-12 text-destructive mx-auto" />
|
||||
<h1 className="text-xl font-semibold">{error}</h1>
|
||||
<Button onClick={() => router.push('/dashboard')}>Return to Dashboard</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="min-h-svh bg-black flex flex-col"
|
||||
onMouseMove={() => isFullscreen && setShowToolbar(true)}
|
||||
>
|
||||
<div className={`transition-opacity duration-300 ${showToolbar ? 'opacity-100' : 'opacity-0'}`}>
|
||||
<ViewerToolbar
|
||||
session={session}
|
||||
connectionState={connectionState}
|
||||
isFullscreen={isFullscreen}
|
||||
quality={quality}
|
||||
isMuted={isMuted}
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
onQualityChange={setQuality}
|
||||
onToggleMute={() => setIsMuted(!isMuted)}
|
||||
onDisconnect={endSession}
|
||||
onReconnect={simulateConnection}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex items-center justify-center relative">
|
||||
{connectionState === 'connecting' && (
|
||||
<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" />
|
||||
<div>
|
||||
<p className="font-semibold">Connecting to remote machine...</p>
|
||||
<p className="text-sm text-muted-foreground">{session?.machineName || 'Establishing connection'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="max-w-full max-h-full object-contain cursor-default"
|
||||
style={{
|
||||
display: connectionState === 'connected' ? 'block' : 'none',
|
||||
imageRendering: quality === 'low' ? 'pixelated' : 'auto',
|
||||
}}
|
||||
onMouseMove={handleMouseMove}
|
||||
onClick={handleMouseClick}
|
||||
onContextMenu={(e) => { e.preventDefault(); handleMouseClick(e) }}
|
||||
/>
|
||||
|
||||
{connectionState === 'disconnected' && (
|
||||
<div className="text-center space-y-4">
|
||||
<Monitor className="h-16 w-16 text-muted-foreground mx-auto" />
|
||||
<div>
|
||||
<p className="font-semibold">Session Ended</p>
|
||||
<p className="text-sm text-muted-foreground">The remote connection has been closed</p>
|
||||
</div>
|
||||
<Button onClick={() => router.push('/dashboard')}>Return to Dashboard</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{connectionState === 'connected' && <ConnectionStatus quality={quality} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user