From b2be19ed14681d255b2bc0aac22c29876cfb5e9e Mon Sep 17 00:00:00 2001 From: monoadmin Date: Fri, 10 Apr 2026 15:36:33 -0700 Subject: [PATCH] Initial commit --- .env.example | 18 + .gitignore | 18 + Dockerfile | 41 + README.md | 0 app/(dashboard)/dashboard/admin/page.tsx | 254 ++ app/(dashboard)/dashboard/connect/page.tsx | 138 + app/(dashboard)/dashboard/machines/page.tsx | 117 + app/(dashboard)/dashboard/page.tsx | 162 + app/(dashboard)/dashboard/sessions/page.tsx | 106 + app/(dashboard)/dashboard/settings/page.tsx | 138 + app/(dashboard)/download/page.tsx | 151 + app/(dashboard)/layout.tsx | 36 + app/api/agent/heartbeat/route.ts | 51 + app/api/agent/register/route.ts | 46 + app/api/agent/session-code/route.ts | 69 + app/api/auth/[...nextauth]/route.ts | 3 + app/api/connect/route.ts | 67 + app/api/invites/accept/route.ts | 72 + app/api/invites/route.ts | 76 + app/api/machines/[id]/route.ts | 29 + app/api/profile/route.ts | 32 + app/api/sessions/[id]/route.ts | 53 + app/api/signal/route.ts | 89 + app/auth/error/page.tsx | 53 + app/auth/invite/[token]/invite-form.tsx | 136 + app/auth/invite/[token]/page.tsx | 100 + app/auth/login/page.tsx | 153 + app/auth/sign-up-success/page.tsx | 46 + app/auth/sign-up/page.tsx | 41 + app/globals.css | 166 + app/layout.tsx | 50 + app/page.tsx | 212 + app/viewer/[sessionId]/page.tsx | 278 ++ auth.ts | 64 + components.json | 21 + components/dashboard/header.tsx | 124 + components/dashboard/machine-actions.tsx | 106 + components/dashboard/mobile-nav.tsx | 115 + components/dashboard/sidebar.tsx | 102 + components/theme-provider.tsx | 11 + components/ui/accordion.tsx | 66 + components/ui/alert-dialog.tsx | 157 + components/ui/alert.tsx | 66 + components/ui/aspect-ratio.tsx | 11 + components/ui/avatar.tsx | 53 + components/ui/badge.tsx | 46 + components/ui/breadcrumb.tsx | 109 + components/ui/button-group.tsx | 83 + components/ui/button.tsx | 60 + components/ui/calendar.tsx | 213 + components/ui/card.tsx | 92 + components/ui/carousel.tsx | 241 ++ components/ui/chart.tsx | 353 ++ components/ui/checkbox.tsx | 32 + components/ui/collapsible.tsx | 33 + components/ui/command.tsx | 184 + components/ui/context-menu.tsx | 252 ++ components/ui/dialog.tsx | 143 + components/ui/drawer.tsx | 135 + components/ui/dropdown-menu.tsx | 257 ++ components/ui/empty.tsx | 104 + components/ui/field.tsx | 244 ++ components/ui/form.tsx | 167 + components/ui/hover-card.tsx | 44 + components/ui/input-group.tsx | 169 + components/ui/input-otp.tsx | 77 + components/ui/input.tsx | 21 + components/ui/item.tsx | 193 + components/ui/kbd.tsx | 28 + components/ui/label.tsx | 24 + components/ui/menubar.tsx | 276 ++ components/ui/navigation-menu.tsx | 166 + components/ui/pagination.tsx | 127 + components/ui/popover.tsx | 48 + components/ui/progress.tsx | 31 + components/ui/radio-group.tsx | 45 + components/ui/resizable.tsx | 56 + components/ui/scroll-area.tsx | 58 + components/ui/select.tsx | 185 + components/ui/separator.tsx | 28 + components/ui/sheet.tsx | 139 + components/ui/sidebar.tsx | 726 ++++ components/ui/skeleton.tsx | 13 + components/ui/slider.tsx | 63 + components/ui/sonner.tsx | 25 + components/ui/spinner.tsx | 16 + components/ui/switch.tsx | 31 + components/ui/table.tsx | 116 + components/ui/tabs.tsx | 66 + components/ui/textarea.tsx | 18 + components/ui/toast.tsx | 129 + components/ui/toaster.tsx | 35 + components/ui/toggle-group.tsx | 73 + components/ui/toggle.tsx | 47 + components/ui/tooltip.tsx | 61 + components/ui/use-mobile.tsx | 19 + components/ui/use-toast.ts | 191 + components/viewer/connection-status.tsx | 46 + components/viewer/toolbar.tsx | 200 + db/migrations/0001_initial.sql | 73 + docker-compose.yml | 43 + drizzle.config.ts | 10 + electron-agent/README.md | 88 + electron-agent/package.json | 45 + electron-agent/src/main/index.ts | 253 ++ electron-agent/src/preload/index.ts | 108 + electron-agent/src/renderer/index.html | 350 ++ hooks/use-mobile.ts | 19 + hooks/use-toast.ts | 191 + lib/db/index.ts | 16 + lib/db/schema.ts | 79 + lib/utils.ts | 6 + lib/webrtc/connection.ts | 202 + middleware.ts | 25 + next-env.d.ts | 6 + next.config.mjs | 12 + package.json | 80 + pnpm-lock.yaml | 3986 +++++++++++++++++++ postcss.config.mjs | 8 + public/apple-icon.png | Bin 0 -> 2626 bytes public/icon-dark-32x32.png | Bin 0 -> 585 bytes public/icon-light-32x32.png | Bin 0 -> 566 bytes public/icon.svg | 26 + public/placeholder-logo.png | Bin 0 -> 568 bytes public/placeholder-logo.svg | 1 + public/placeholder-user.jpg | Bin 0 -> 1635 bytes public/placeholder.jpg | Bin 0 -> 1064 bytes public/placeholder.svg | 1 + scripts/001_create_schema.sql | 126 + scripts/002_profile_trigger.sql | 26 + styles/globals.css | 125 + supabase/migrations/001_invites.sql | 30 + tsconfig.json | 41 + types/next-auth.d.ts | 23 + 134 files changed, 16234 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/(dashboard)/dashboard/admin/page.tsx create mode 100644 app/(dashboard)/dashboard/connect/page.tsx create mode 100644 app/(dashboard)/dashboard/machines/page.tsx create mode 100644 app/(dashboard)/dashboard/page.tsx create mode 100644 app/(dashboard)/dashboard/sessions/page.tsx create mode 100644 app/(dashboard)/dashboard/settings/page.tsx create mode 100644 app/(dashboard)/download/page.tsx create mode 100644 app/(dashboard)/layout.tsx create mode 100644 app/api/agent/heartbeat/route.ts create mode 100644 app/api/agent/register/route.ts create mode 100644 app/api/agent/session-code/route.ts create mode 100644 app/api/auth/[...nextauth]/route.ts create mode 100644 app/api/connect/route.ts create mode 100644 app/api/invites/accept/route.ts create mode 100644 app/api/invites/route.ts create mode 100644 app/api/machines/[id]/route.ts create mode 100644 app/api/profile/route.ts create mode 100644 app/api/sessions/[id]/route.ts create mode 100644 app/api/signal/route.ts create mode 100644 app/auth/error/page.tsx create mode 100644 app/auth/invite/[token]/invite-form.tsx create mode 100644 app/auth/invite/[token]/page.tsx create mode 100644 app/auth/login/page.tsx create mode 100644 app/auth/sign-up-success/page.tsx create mode 100644 app/auth/sign-up/page.tsx create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/page.tsx create mode 100644 app/viewer/[sessionId]/page.tsx create mode 100644 auth.ts create mode 100644 components.json create mode 100644 components/dashboard/header.tsx create mode 100644 components/dashboard/machine-actions.tsx create mode 100644 components/dashboard/mobile-nav.tsx create mode 100644 components/dashboard/sidebar.tsx create mode 100644 components/theme-provider.tsx create mode 100644 components/ui/accordion.tsx create mode 100644 components/ui/alert-dialog.tsx create mode 100644 components/ui/alert.tsx create mode 100644 components/ui/aspect-ratio.tsx create mode 100644 components/ui/avatar.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/breadcrumb.tsx create mode 100644 components/ui/button-group.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/calendar.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/carousel.tsx create mode 100644 components/ui/chart.tsx create mode 100644 components/ui/checkbox.tsx create mode 100644 components/ui/collapsible.tsx create mode 100644 components/ui/command.tsx create mode 100644 components/ui/context-menu.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/drawer.tsx create mode 100644 components/ui/dropdown-menu.tsx create mode 100644 components/ui/empty.tsx create mode 100644 components/ui/field.tsx create mode 100644 components/ui/form.tsx create mode 100644 components/ui/hover-card.tsx create mode 100644 components/ui/input-group.tsx create mode 100644 components/ui/input-otp.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/item.tsx create mode 100644 components/ui/kbd.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/menubar.tsx create mode 100644 components/ui/navigation-menu.tsx create mode 100644 components/ui/pagination.tsx create mode 100644 components/ui/popover.tsx create mode 100644 components/ui/progress.tsx create mode 100644 components/ui/radio-group.tsx create mode 100644 components/ui/resizable.tsx create mode 100644 components/ui/scroll-area.tsx create mode 100644 components/ui/select.tsx create mode 100644 components/ui/separator.tsx create mode 100644 components/ui/sheet.tsx create mode 100644 components/ui/sidebar.tsx create mode 100644 components/ui/skeleton.tsx create mode 100644 components/ui/slider.tsx create mode 100644 components/ui/sonner.tsx create mode 100644 components/ui/spinner.tsx create mode 100644 components/ui/switch.tsx create mode 100644 components/ui/table.tsx create mode 100644 components/ui/tabs.tsx create mode 100644 components/ui/textarea.tsx create mode 100644 components/ui/toast.tsx create mode 100644 components/ui/toaster.tsx create mode 100644 components/ui/toggle-group.tsx create mode 100644 components/ui/toggle.tsx create mode 100644 components/ui/tooltip.tsx create mode 100644 components/ui/use-mobile.tsx create mode 100644 components/ui/use-toast.ts create mode 100644 components/viewer/connection-status.tsx create mode 100644 components/viewer/toolbar.tsx create mode 100644 db/migrations/0001_initial.sql create mode 100644 docker-compose.yml create mode 100644 drizzle.config.ts create mode 100644 electron-agent/README.md create mode 100644 electron-agent/package.json create mode 100644 electron-agent/src/main/index.ts create mode 100644 electron-agent/src/preload/index.ts create mode 100644 electron-agent/src/renderer/index.html create mode 100644 hooks/use-mobile.ts create mode 100644 hooks/use-toast.ts create mode 100644 lib/db/index.ts create mode 100644 lib/db/schema.ts create mode 100644 lib/utils.ts create mode 100644 lib/webrtc/connection.ts create mode 100644 middleware.ts create mode 100644 next-env.d.ts create mode 100644 next.config.mjs create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 postcss.config.mjs create mode 100644 public/apple-icon.png create mode 100644 public/icon-dark-32x32.png create mode 100644 public/icon-light-32x32.png create mode 100644 public/icon.svg create mode 100644 public/placeholder-logo.png create mode 100644 public/placeholder-logo.svg create mode 100644 public/placeholder-user.jpg create mode 100644 public/placeholder.jpg create mode 100644 public/placeholder.svg create mode 100644 scripts/001_create_schema.sql create mode 100644 scripts/002_profile_trigger.sql create mode 100644 styles/globals.css create mode 100644 supabase/migrations/001_invites.sql create mode 100644 tsconfig.json create mode 100644 types/next-auth.d.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c1e6e0e --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# ------------------------------------------------------- +# RemoteLink — Environment Variables +# Copy this file to .env and fill in the values +# ------------------------------------------------------- + +# PostgreSQL credentials (used by docker-compose) +POSTGRES_USER=remotelink +POSTGRES_PASSWORD=change_me_strong_password +POSTGRES_DB=remotelink + +# Full database URL (auto-built from above in docker-compose, but set here for local dev) +DATABASE_URL=postgresql://remotelink:change_me_strong_password@localhost:5432/remotelink + +# NextAuth secret — generate with: openssl rand -base64 32 +AUTH_SECRET=change_me_generate_with_openssl_rand_base64_32 + +# Public URL of the app (used for invite links, etc.) +NEXT_PUBLIC_APP_URL=http://localhost:3000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bdbabca --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# v0 sandbox internal files +__v0_runtime_loader.js +__v0_devtools.tsx +__v0_jsx-dev-runtime.ts +.snowflake/ +.v0-trash/ +.vercel/ +next.user-config.* + +# Environment variables +.env +.env.* +!.env.example + +# Common ignores +node_modules/ +.next/ +.DS_Store \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9d2c174 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +# ---- Dependencies ---- +FROM node:22-alpine AS deps +RUN corepack enable +WORKDIR /app +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile + +# ---- Builder ---- +FROM node:22-alpine AS builder +RUN corepack enable +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +ARG NEXT_PUBLIC_APP_URL=http://localhost:3000 +ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN pnpm build + +# ---- Runner ---- +FROM node:22-alpine AS runner +RUN corepack enable +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["node", "server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/app/(dashboard)/dashboard/admin/page.tsx b/app/(dashboard)/dashboard/admin/page.tsx new file mode 100644 index 0000000..c7f19fd --- /dev/null +++ b/app/(dashboard)/dashboard/admin/page.tsx @@ -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 ( + + ) +} + +export default function AdminPage() { + const [invites, setInvites] = useState([]) + const [email, setEmail] = useState('') + const [isLoading, setIsLoading] = useState(true) + const [isCreating, setIsCreating] = useState(false) + const [error, setError] = useState(null) + const [successEmail, setSuccessEmail] = useState(null) + const [newToken, setNewToken] = useState(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 ( + + Used + + ) + if (status === 'expired') + return ( + + Expired + + ) + return ( + + Pending + + ) + } + + return ( +
+
+

Admin

+

Manage user invitations

+
+ + {/* Create invite */} + + + + + Invite user + + + Send an invite link to a new user. Links expire after 7 days. + + + +
+
+ +
+ setEmail(e.target.value)} + className="bg-secondary/50" + /> + +
+
+ + {error && ( +
+

{error}

+
+ )} + + {newToken && successEmail && ( +
+

+ Invite created for {successEmail} +

+
+ + {inviteUrl(newToken)} + + +
+
+ )} +
+
+
+ + {/* Invites list */} + + + Invitations + All invite links, newest first + + + {isLoading ? ( +
+ +
+ ) : invites.length === 0 ? ( +

+ No invitations yet +

+ ) : ( +
+ {invites.map((invite) => { + const status = inviteStatus(invite) + return ( +
+
+

{invite.email}

+
+ {statusBadge(invite)} + + {new Date(invite.created_at).toLocaleDateString()} + +
+
+
+ {status === 'pending' && ( + + )} + +
+
+ ) + })} +
+ )} +
+
+
+ ) +} diff --git a/app/(dashboard)/dashboard/connect/page.tsx b/app/(dashboard)/dashboard/connect/page.tsx new file mode 100644 index 0000000..953cd5a --- /dev/null +++ b/app/(dashboard)/dashboard/connect/page.tsx @@ -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(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 ( +
+
+

Quick Connect

+

Enter a session code to connect to a remote machine

+
+ + + +
+ +
+ Enter Session Code + + Ask the person on the remote machine to generate a code from their RemoteLink agent + +
+ +
+
+ + setSessionCode(formatCode(e.target.value))} + maxLength={7} + className="text-center text-3xl font-mono tracking-widest h-16 bg-secondary/50" + autoComplete="off" + autoFocus + /> +
+ + {error && ( +
+ +

{error}

+
+ )} + + +
+
+
+ +
+
+ +
+

Codes expire

+

Session codes are valid for 10 minutes

+
+
+
+ +
+

Secure connection

+

All sessions are end-to-end encrypted

+
+
+
+
+ ) +} diff --git a/app/(dashboard)/dashboard/machines/page.tsx b/app/(dashboard)/dashboard/machines/page.tsx new file mode 100644 index 0000000..3782ad2 --- /dev/null +++ b/app/(dashboard)/dashboard/machines/page.tsx @@ -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 ( +
+
+
+

Machines

+

+ {machineList.length} machine{machineList.length !== 1 ? 's' : ''} registered, {onlineCount} online +

+
+ +
+ + {machineList.length > 0 ? ( +
+ {machineList.map((machine) => ( + + +
+
+ +
+
+ {machine.name} + {machine.hostname || 'Unknown host'} +
+
+ +
+ +
+ Status + + + {machine.isOnline ? 'Online' : 'Offline'} + +
+
+ OS + {machine.os || 'Unknown'} {machine.osVersion || ''} +
+
+ Last seen + + {machine.lastSeen + ? formatDistanceToNow(new Date(machine.lastSeen), { addSuffix: true }) + : 'Never'} + +
+
+ Agent + {machine.agentVersion || 'N/A'} +
+
+ +
+
+
+ ))} +
+ ) : ( + + + +

No machines yet

+

+ Download and install the RemoteLink agent on the machines you want to control remotely. +

+ +
+
+ )} +
+ ) +} diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx new file mode 100644 index 0000000..c6f9f39 --- /dev/null +++ b/app/(dashboard)/dashboard/page.tsx @@ -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 ( +
+
+ + + Total Machines + + + +
{machineList.length}
+

{onlineMachines.length} online

+
+
+ + + + Recent Sessions + + + +
{sessionList.length}
+

Last 5 sessions

+
+
+ + + + Quick Connect + + + + + + +
+ +
+ + +
+ Your Machines + Registered remote machines +
+ +
+ + {machineList.length > 0 ? ( +
+ {machineList.slice(0, 5).map((machine) => ( +
+
+
+
+

{machine.name}

+

{machine.os} {machine.osVersion}

+
+
+
+ {machine.lastSeen + ? formatDistanceToNow(new Date(machine.lastSeen), { addSuffix: true }) + : 'Never connected'} +
+
+ ))} +
+ ) : ( +
+ +

No machines registered yet

+ +
+ )} + + + + + +
+ Recent Sessions + Your connection history +
+ +
+ + {sessionList.length > 0 ? ( +
+ {sessionList.map((s) => ( +
+
+ {s.endedAt ? ( + + ) : ( + + )} +
+

{s.machineName || 'Unknown Machine'}

+

+ {s.connectionType === 'session_code' ? 'Session Code' : 'Direct'} connection +

+
+
+
+ + {formatDistanceToNow(new Date(s.startedAt), { addSuffix: true })} +
+
+ ))} +
+ ) : ( +
+ +

No sessions yet

+ +
+ )} +
+
+
+
+ ) +} diff --git a/app/(dashboard)/dashboard/sessions/page.tsx b/app/(dashboard)/dashboard/sessions/page.tsx new file mode 100644 index 0000000..6aa7ae9 --- /dev/null +++ b/app/(dashboard)/dashboard/sessions/page.tsx @@ -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 ( +
+
+

Session History

+

View your remote connection history

+
+ + {sessionList.length > 0 ? ( +
+ {sessionList.map((s) => ( + + +
+
+
+ +
+
+
+

{s.machineName || 'Unknown Machine'}

+ {s.endedAt + ? + : } +
+
+ + + {s.connectionType === 'session_code' ? 'Session Code' : 'Direct'} + + {s.sessionCode && ( + {s.sessionCode} + )} +
+
+
+ +
+
+

Started

+

{format(new Date(s.startedAt), 'MMM d, h:mm a')}

+
+
+

Duration

+

+ + {s.endedAt ? formatDuration(s.durationSeconds) : 'Active'} +

+
+
+

Status

+

+ {s.endedAt ? 'Completed' : 'Active'} +

+
+
+
+ {s.notes && ( +
+

{s.notes}

+
+ )} +
+
+ ))} +
+ ) : ( + + + +

No sessions yet

+

+ Your remote session history will appear here once you start connecting to machines. +

+
+
+ )} +
+ ) +} diff --git a/app/(dashboard)/dashboard/settings/page.tsx b/app/(dashboard)/dashboard/settings/page.tsx new file mode 100644 index 0000000..54c5998 --- /dev/null +++ b/app/(dashboard)/dashboard/settings/page.tsx @@ -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 ( +
+ +
+ ) + } + + return ( +
+
+

Settings

+

Manage your account settings and preferences

+
+ + + + + + Profile + + Update your personal information + + +
+
+ + setFullName(e.target.value)} + placeholder="John Doe" + className="bg-secondary/50" + /> +
+
+ + setCompany(e.target.value)} + placeholder="Acme Inc." + className="bg-secondary/50" + /> +
+ + {message && ( +
+ {message.text} +
+ )} + + +
+
+
+ + + + Account + Your account details + + +
+
+

Email

+

{email}

+
+
+
+
+

Role

+

{role}

+
+
+
+
+
+ ) +} diff --git a/app/(dashboard)/download/page.tsx b/app/(dashboard)/download/page.tsx new file mode 100644 index 0000000..1c2efbd --- /dev/null +++ b/app/(dashboard)/download/page.tsx @@ -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 ( +
+
+

Download RemoteLink Agent

+

+ Install the agent on machines you want to control remotely. + The agent runs in the background and enables secure connections. +

+
+ +
+ {platforms.map((platform) => ( + + +
+ +
+ {platform.name} + {platform.description} +
+ +
+

{platform.filename}

+

{platform.size}

+
+ +
+
+ ))} +
+ + + +

Agent Features

+
+ {features.map((feature) => ( +
+
+ +
+
+

{feature.title}

+

{feature.description}

+
+
+ ))} +
+
+
+ + + + Installation Instructions + + +
+

Windows

+
    +
  1. Download and run RemoteLink-Setup.exe
  2. +
  3. Follow the installation wizard
  4. +
  5. The agent will start automatically and appear in your system tray
  6. +
  7. Click the tray icon to generate a session code
  8. +
+
+
+

macOS

+
    +
  1. Download and open RemoteLink.dmg
  2. +
  3. Drag RemoteLink to your Applications folder
  4. +
  5. Open RemoteLink from Applications
  6. +
  7. Grant accessibility permissions when prompted
  8. +
+
+
+

Linux

+
    +
  1. Download the AppImage file
  2. +
  3. Make it executable: chmod +x remotelink-agent.AppImage
  4. +
  5. Run the AppImage
  6. +
  7. The agent will appear in your system tray
  8. +
+
+
+
+
+ ) +} diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..7257550 --- /dev/null +++ b/app/(dashboard)/layout.tsx @@ -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 ( +
+ +
+ +
+ {children} +
+
+
+ ) +} diff --git a/app/api/agent/heartbeat/route.ts b/app/api/agent/heartbeat/route.ts new file mode 100644 index 0000000..8065117 --- /dev/null +++ b/app/api/agent/heartbeat/route.ts @@ -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 }) + } +} diff --git a/app/api/agent/register/route.ts b/app/api/agent/register/route.ts new file mode 100644 index 0000000..c38117e --- /dev/null +++ b/app/api/agent/register/route.ts @@ -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 }) + } +} diff --git a/app/api/agent/session-code/route.ts b/app/api/agent/session-code/route.ts new file mode 100644 index 0000000..db88967 --- /dev/null +++ b/app/api/agent/session-code/route.ts @@ -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 }) + } +} diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..c4ea295 --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from '@/auth' + +export const { GET, POST } = handlers diff --git a/app/api/connect/route.ts b/app/api/connect/route.ts new file mode 100644 index 0000000..cfbe701 --- /dev/null +++ b/app/api/connect/route.ts @@ -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 }) +} diff --git a/app/api/invites/accept/route.ts b/app/api/invites/accept/route.ts new file mode 100644 index 0000000..17316a1 --- /dev/null +++ b/app/api/invites/accept/route.ts @@ -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 }) +} diff --git a/app/api/invites/route.ts b/app/api/invites/route.ts new file mode 100644 index 0000000..c6f92b7 --- /dev/null +++ b/app/api/invites/route.ts @@ -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 }) +} diff --git a/app/api/machines/[id]/route.ts b/app/api/machines/[id]/route.ts new file mode 100644 index 0000000..1871aea --- /dev/null +++ b/app/api/machines/[id]/route.ts @@ -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 }) +} diff --git a/app/api/profile/route.ts b/app/api/profile/route.ts new file mode 100644 index 0000000..4ee6905 --- /dev/null +++ b/app/api/profile/route.ts @@ -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 }) +} diff --git a/app/api/sessions/[id]/route.ts b/app/api/sessions/[id]/route.ts new file mode 100644 index 0000000..d2c8bef --- /dev/null +++ b/app/api/sessions/[id]/route.ts @@ -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 = {} + 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 }) +} diff --git a/app/api/signal/route.ts b/app/api/signal/route.ts new file mode 100644 index 0000000..af1f400 --- /dev/null +++ b/app/api/signal/route.ts @@ -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() + +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 }) + } +} diff --git a/app/auth/error/page.tsx b/app/auth/error/page.tsx new file mode 100644 index 0000000..59aa4bd --- /dev/null +++ b/app/auth/error/page.tsx @@ -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 ( +
+
+
+
+
+ +
+ RemoteLink +
+ + + +
+ +
+ Authentication Error + + Something went wrong during authentication. This could be due to an expired link or an invalid request. + +
+ +
+

+ If you continue to experience issues, please contact support or try again later. +

+
+
+ + +
+
+
+
+
+
+ ) +} diff --git a/app/auth/invite/[token]/invite-form.tsx b/app/auth/invite/[token]/invite-form.tsx new file mode 100644 index 0000000..0ab7323 --- /dev/null +++ b/app/auth/invite/[token]/invite-form.tsx @@ -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(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 ( + + + Set up your account + + You were invited to join RemoteLink + + + +
+
+
+ + +
+
+ + setFullName(e.target.value)} + className="bg-secondary/50" + /> +
+
+ + setPassword(e.target.value)} + className="bg-secondary/50" + /> +
+
+ + setConfirmPassword(e.target.value)} + className="bg-secondary/50" + /> +
+ {error && ( +
+

{error}

+
+ )} + +
+
+
+
+ ) +} diff --git a/app/auth/invite/[token]/page.tsx b/app/auth/invite/[token]/page.tsx new file mode 100644 index 0000000..033b447 --- /dev/null +++ b/app/auth/invite/[token]/page.tsx @@ -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 ( +
+
+
+
+
+ +
+ RemoteLink +
+

+ {isValid ? "You've been invited" : 'RemoteLink'} +

+

+ {isValid + ? 'Set up your account to start managing remote machines securely.' + : 'Secure, low-latency remote desktop access for IT professionals.'} +

+
+
+ +
+
+
+
+
+ +
+ RemoteLink +
+ + {isValid ? ( + + ) : ( + + +
+ +
+ + {!invite + ? 'Invalid invite' + : isUsed + ? 'Already used' + : 'Invite expired'} + + + {!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.'} + +
+ + + +
+ )} +
+
+
+
+ ) +} diff --git a/app/auth/login/page.tsx b/app/auth/login/page.tsx new file mode 100644 index 0000000..744a9c6 --- /dev/null +++ b/app/auth/login/page.tsx @@ -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(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 ( + + + Welcome back + + Sign in to access your remote machines + + + + {invited && ( +
+

+ Account created! You can now sign in. +

+
+ )} +
+
+
+ + setEmail(e.target.value)} + className="bg-secondary/50" + /> +
+
+ + setPassword(e.target.value)} + className="bg-secondary/50" + /> +
+ {error && ( +
+

{error}

+
+ )} + +
+
+ Access is by invitation only +
+
+
+
+ ) +} + +export default function LoginPage() { + return ( +
+
+
+
+
+ +
+ RemoteLink +
+

+ Professional Remote Desktop Solution +

+

+ Securely connect to and control remote machines. Perfect for IT support, + system administration, and remote assistance. +

+
+ + End-to-end encrypted connections +
+
+
+ +
+
+
+
+
+ +
+ RemoteLink +
+ + + + + +

+ Self-hosted remote desktop. End-to-end encrypted. +

+
+
+
+
+ ) +} diff --git a/app/auth/sign-up-success/page.tsx b/app/auth/sign-up-success/page.tsx new file mode 100644 index 0000000..8f49ded --- /dev/null +++ b/app/auth/sign-up-success/page.tsx @@ -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 ( +
+
+
+
+
+ +
+ RemoteLink +
+ + + +
+ +
+ Check your email + + {"We've sent you a confirmation link. Please check your inbox and click the link to activate your account."} + +
+ +
+

+ {"Didn't receive the email? Check your spam folder or try signing up again with a different email address."} +

+
+ +
+
+
+
+
+ ) +} diff --git a/app/auth/sign-up/page.tsx b/app/auth/sign-up/page.tsx new file mode 100644 index 0000000..4d05296 --- /dev/null +++ b/app/auth/sign-up/page.tsx @@ -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 ( +
+
+
+
+
+ +
+ RemoteLink +
+ + + +
+ +
+ Invite only + + RemoteLink is invite-only. To get access, contact your administrator for an invite link. + +
+ + + +
+
+
+
+ ) +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..4542649 --- /dev/null +++ b/app/globals.css @@ -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); +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..890767a --- /dev/null +++ b/app/layout.tsx @@ -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 ( + + + {children} + + + ) +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..64a7b28 --- /dev/null +++ b/app/page.tsx @@ -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 ( +
+ {/* Navigation */} + + + {/* Hero Section */} +
+
+
+
+ + Secure Remote Access +
+

+ Remote Desktop + Made Simple +

+

+ Connect to and control remote machines securely. Perfect for IT support, + system administration, and helping friends and family. +

+
+ + +
+
+
+ + {/* Background decoration */} +
+
+
+
+ + {/* How it works */} +
+
+
+

How It Works

+

+ Get connected in three simple steps +

+
+
+
+
+ 1 +
+

Install the Agent

+

+ Download and run our lightweight agent on the remote machine +

+
+
+
+ 2 +
+

Get a Session Code

+

+ The agent generates a 6-digit code that expires in 10 minutes +

+
+
+
+ 3 +
+

Connect & Control

+

+ Enter the code in your dashboard and take full control +

+
+
+
+
+ + {/* Features Grid */} +
+
+
+

Built for IT Professionals

+

+ Everything you need for efficient remote support +

+
+
+ {features.map((feature, index) => ( +
+
+ +
+

{feature.title}

+

{feature.description}

+
+ ))} +
+
+
+ + {/* CTA Section */} +
+
+
+

Ready to connect?

+

+ Sign in to your account or contact your administrator for an invite. +

+ +
+
+
+ + {/* Footer */} +
+
+
+
+
+ +
+ RemoteLink +
+

+ Built with WebRTC. End-to-end encrypted. +

+
+
+
+
+ ) +} diff --git a/app/viewer/[sessionId]/page.tsx b/app/viewer/[sessionId]/page.tsx new file mode 100644 index 0000000..a819d45 --- /dev/null +++ b/app/viewer/[sessionId]/page.tsx @@ -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(null) + const [connectionState, setConnectionState] = useState('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(null) + + const containerRef = useRef(null) + const canvasRef = useRef(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) => { + 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) => { + 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 ( +
+
+ +

{error}

+ +
+
+ ) + } + + return ( +
isFullscreen && setShowToolbar(true)} + > +
+ setIsMuted(!isMuted)} + onDisconnect={endSession} + onReconnect={simulateConnection} + /> +
+ +
+ {connectionState === 'connecting' && ( +
+
+ +
+

Connecting to remote machine...

+

{session?.machineName || 'Establishing connection'}

+
+
+
+ )} + + { e.preventDefault(); handleMouseClick(e) }} + /> + + {connectionState === 'disconnected' && ( +
+ +
+

Session Ended

+

The remote connection has been closed

+
+ +
+ )} +
+ + {connectionState === 'connected' && } +
+ ) +} diff --git a/auth.ts b/auth.ts new file mode 100644 index 0000000..bc067a1 --- /dev/null +++ b/auth.ts @@ -0,0 +1,64 @@ +import NextAuth from 'next-auth' +import Credentials from 'next-auth/providers/credentials' +import { db } from '@/lib/db' +import { users } from '@/lib/db/schema' +import { eq } from 'drizzle-orm' +import bcrypt from 'bcryptjs' + +export const { handlers, signIn, signOut, auth } = NextAuth({ + providers: [ + Credentials({ + credentials: { + email: { label: 'Email', type: 'email' }, + password: { label: 'Password', type: 'password' }, + }, + authorize: async (credentials) => { + const email = credentials?.email as string + const password = credentials?.password as string + + if (!email || !password) return null + + const result = await db + .select() + .from(users) + .where(eq(users.email, email.toLowerCase().trim())) + .limit(1) + + const user = result[0] + if (!user) return null + + const valid = await bcrypt.compare(password, user.passwordHash) + if (!valid) return null + + return { + id: user.id, + email: user.email, + name: user.fullName ?? user.email.split('@')[0], + role: user.role, + } + }, + }), + ], + callbacks: { + jwt({ token, user }) { + if (user) { + token.id = user.id + token.role = (user as { role: string }).role + } + return token + }, + session({ session, token }) { + if (session.user) { + session.user.id = token.id as string + ;(session.user as { role: string }).role = token.role as string + } + return session + }, + }, + pages: { + signIn: '/auth/login', + error: '/auth/error', + }, + session: { strategy: 'jwt' }, + trustHost: true, +}) diff --git a/components.json b/components.json new file mode 100644 index 0000000..4ee62ee --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/components/dashboard/header.tsx b/components/dashboard/header.tsx new file mode 100644 index 0000000..859196e --- /dev/null +++ b/components/dashboard/header.tsx @@ -0,0 +1,124 @@ +'use client' + +import { usePathname, useRouter } from 'next/navigation' +import { signOut } from 'next-auth/react' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { Menu, LogOut, Settings, User as UserIcon, Monitor } from 'lucide-react' +import Link from 'next/link' +import { MobileNav } from './mobile-nav' +import { useState } from 'react' + +interface Profile { + fullName: string | null + company: string | null + role: string | null +} + +interface AuthUser { + id: string + email: string + name: string +} + +interface DashboardHeaderProps { + user: AuthUser + profile: Profile | null +} + +const pageTitles: Record = { + '/dashboard': 'Overview', + '/dashboard/machines': 'Machines', + '/dashboard/connect': 'Quick Connect', + '/dashboard/sessions': 'Session History', + '/dashboard/settings': 'Settings', + '/dashboard/admin': 'Admin', + '/download': 'Download Agent', +} + +export function DashboardHeader({ user, profile }: DashboardHeaderProps) { + const pathname = usePathname() + const router = useRouter() + const [mobileNavOpen, setMobileNavOpen] = useState(false) + + const handleSignOut = async () => { + await signOut({ redirect: false }) + router.push('/auth/login') + } + + const displayName = profile?.fullName || user.name || user.email?.split('@')[0] + const title = pageTitles[pathname] || 'Dashboard' + + return ( + <> +
+
+ + + +
+ +
+ + +

{title}

+
+ + + + + + +
+

{displayName}

+

{user.email}

+
+ + + + + Profile + + + + + + Settings + + + + + + Sign out + +
+
+
+ + setMobileNavOpen(false)} + profile={profile} + /> + + ) +} diff --git a/components/dashboard/machine-actions.tsx b/components/dashboard/machine-actions.tsx new file mode 100644 index 0000000..dc6c6c5 --- /dev/null +++ b/components/dashboard/machine-actions.tsx @@ -0,0 +1,106 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { MoreHorizontal, Trash2, RefreshCw, Copy } from 'lucide-react' + +interface Machine { + id: string + name: string + hostname: string | null + os: string | null + osVersion: string | null + isOnline: boolean + accessKey: string + lastSeen: Date | null +} + +interface MachineActionsProps { + machine: Machine +} + +export function MachineActions({ machine }: MachineActionsProps) { + const [showDeleteDialog, setShowDeleteDialog] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) + const router = useRouter() + + const handleDelete = async () => { + setIsDeleting(true) + const res = await fetch(`/api/machines/${machine.id}`, { method: 'DELETE' }) + if (res.ok) router.refresh() + setIsDeleting(false) + setShowDeleteDialog(false) + } + + const copyAccessKey = () => navigator.clipboard.writeText(machine.accessKey) + + return ( + <> + + + + + + + + Copy Access Key + + router.refresh()}> + + Refresh Status + + + setShowDeleteDialog(true)} + className="text-destructive focus:text-destructive" + > + + Delete Machine + + + + + + + + Delete machine? + + This will remove {`"${machine.name}"`} from your account. The agent on the remote machine will need to be re-registered. + + + + Cancel + + {isDeleting ? 'Deleting...' : 'Delete'} + + + + + + ) +} diff --git a/components/dashboard/mobile-nav.tsx b/components/dashboard/mobile-nav.tsx new file mode 100644 index 0000000..c3194bc --- /dev/null +++ b/components/dashboard/mobile-nav.tsx @@ -0,0 +1,115 @@ +'use client' + +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { + Monitor, + LayoutDashboard, + Laptop, + History, + Settings, + Download, + Link2, + ShieldCheck, + X +} from 'lucide-react' + +interface Profile { + fullName: string | null + company: string | null + role: string | null +} + +interface MobileNavProps { + open: boolean + onClose: () => void + profile: Profile | null +} + +const navItems = [ + { href: '/dashboard', icon: LayoutDashboard, label: 'Overview' }, + { href: '/dashboard/machines', icon: Laptop, label: 'Machines' }, + { href: '/dashboard/connect', icon: Link2, label: 'Quick Connect' }, + { href: '/dashboard/sessions', icon: History, label: 'Sessions' }, + { href: '/download', icon: Download, label: 'Download Agent' }, + { href: '/dashboard/settings', icon: Settings, label: 'Settings' }, +] + +const adminNavItems = [ + { href: '/dashboard/admin', icon: ShieldCheck, label: 'Admin' }, +] + +export function MobileNav({ open, onClose, profile }: MobileNavProps) { + const pathname = usePathname() + const isAdmin = profile?.role === 'admin' + const allNavItems = isAdmin ? [...navItems, ...adminNavItems] : navItems + + if (!open) return null + + return ( + <> +
+ +
+
+
+
+ +
+ RemoteLink +
+ +
+ + + +
+
+
+ {profile?.fullName?.charAt(0)?.toUpperCase() || 'U'} +
+
+

+ {profile?.fullName || 'User'} +

+

+ {profile?.company || 'Personal'} +

+
+
+
+
+ + ) +} diff --git a/components/dashboard/sidebar.tsx b/components/dashboard/sidebar.tsx new file mode 100644 index 0000000..d75c1a9 --- /dev/null +++ b/components/dashboard/sidebar.tsx @@ -0,0 +1,102 @@ +'use client' + +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { cn } from '@/lib/utils' +import { + Monitor, + LayoutDashboard, + Laptop, + History, + Settings, + Download, + Link2, + ShieldCheck +} from 'lucide-react' + +interface Profile { + fullName: string | null + company: string | null + role: string | null +} + +interface AuthUser { + id: string + email: string + name: string +} + +interface DashboardSidebarProps { + user: AuthUser + profile: Profile | null +} + +const navItems = [ + { href: '/dashboard', icon: LayoutDashboard, label: 'Overview' }, + { href: '/dashboard/machines', icon: Laptop, label: 'Machines' }, + { href: '/dashboard/connect', icon: Link2, label: 'Quick Connect' }, + { href: '/dashboard/sessions', icon: History, label: 'Sessions' }, + { href: '/download', icon: Download, label: 'Download Agent' }, + { href: '/dashboard/settings', icon: Settings, label: 'Settings' }, +] + +const adminNavItems = [ + { href: '/dashboard/admin', icon: ShieldCheck, label: 'Admin' }, +] + +export function DashboardSidebar({ profile }: DashboardSidebarProps) { + const pathname = usePathname() + const isAdmin = profile?.role === 'admin' + const allNavItems = isAdmin ? [...navItems, ...adminNavItems] : navItems + + return ( + + ) +} diff --git a/components/theme-provider.tsx b/components/theme-provider.tsx new file mode 100644 index 0000000..55c2f6e --- /dev/null +++ b/components/theme-provider.tsx @@ -0,0 +1,11 @@ +'use client' + +import * as React from 'react' +import { + ThemeProvider as NextThemesProvider, + type ThemeProviderProps, +} from 'next-themes' + +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + return {children} +} diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx new file mode 100644 index 0000000..e538a33 --- /dev/null +++ b/components/ui/accordion.tsx @@ -0,0 +1,66 @@ +'use client' + +import * as React from 'react' +import * as AccordionPrimitive from '@radix-ui/react-accordion' +import { ChevronDownIcon } from 'lucide-react' + +import { cn } from '@/lib/utils' + +function Accordion({ + ...props +}: React.ComponentProps) { + return +} + +function AccordionItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + svg]:rotate-180', + className, + )} + {...props} + > + {children} + + + + ) +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
{children}
+
+ ) +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/components/ui/alert-dialog.tsx b/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..9704452 --- /dev/null +++ b/components/ui/alert-dialog.tsx @@ -0,0 +1,157 @@ +'use client' + +import * as React from 'react' +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog' + +import { cn } from '@/lib/utils' +import { buttonVariants } from '@/components/ui/button' + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx new file mode 100644 index 0000000..e6751ab --- /dev/null +++ b/components/ui/alert.tsx @@ -0,0 +1,66 @@ +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/lib/utils' + +const alertVariants = cva( + 'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current', + { + variants: { + variant: { + default: 'bg-card text-card-foreground', + destructive: + 'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<'div'> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription } diff --git a/components/ui/aspect-ratio.tsx b/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..40bb120 --- /dev/null +++ b/components/ui/aspect-ratio.tsx @@ -0,0 +1,11 @@ +'use client' + +import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio' + +function AspectRatio({ + ...props +}: React.ComponentProps) { + return +} + +export { AspectRatio } diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx new file mode 100644 index 0000000..aa98465 --- /dev/null +++ b/components/ui/avatar.tsx @@ -0,0 +1,53 @@ +'use client' + +import * as React from 'react' +import * as AvatarPrimitive from '@radix-ui/react-avatar' + +import { cn } from '@/lib/utils' + +function Avatar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000..fc4126b --- /dev/null +++ b/components/ui/badge.tsx @@ -0,0 +1,46 @@ +import * as React from 'react' +import { Slot } from '@radix-ui/react-slot' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/lib/utils' + +const badgeVariants = cva( + 'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden', + { + variants: { + variant: { + default: + 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90', + secondary: + 'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90', + destructive: + 'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', + outline: + 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +) + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<'span'> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : 'span' + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/components/ui/breadcrumb.tsx b/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..1750ff2 --- /dev/null +++ b/components/ui/breadcrumb.tsx @@ -0,0 +1,109 @@ +import * as React from 'react' +import { Slot } from '@radix-ui/react-slot' +import { ChevronRight, MoreHorizontal } from 'lucide-react' + +import { cn } from '@/lib/utils' + +function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) { + return