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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user