404 lines
18 KiB
TypeScript
404 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { useAuth } from "@/lib/auth-context";
|
|
import { api } from "@/lib/api";
|
|
import { QRCodeSVG } from "qrcode.react";
|
|
import { Card } from "@/components/ui/Card";
|
|
import { GradientSpinner } from "@/components/ui/GradientSpinner";
|
|
import {
|
|
Smartphone,
|
|
RefreshCw,
|
|
Check,
|
|
Clock,
|
|
Copy,
|
|
Trash2,
|
|
AlertCircle
|
|
} from "lucide-react";
|
|
import { cn } from "@/utils/cn";
|
|
|
|
interface DeviceLinkCode {
|
|
code: string;
|
|
expiresAt: string;
|
|
expiresIn: number;
|
|
}
|
|
|
|
interface LinkedDevice {
|
|
id: string;
|
|
name: string;
|
|
lastUsed: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
export default function DeviceLinkPage() {
|
|
const router = useRouter();
|
|
const { isAuthenticated, isLoading: authLoading } = useAuth();
|
|
const [linkCode, setLinkCode] = useState<DeviceLinkCode | null>(null);
|
|
const [isGenerating, setIsGenerating] = useState(false);
|
|
const [timeRemaining, setTimeRemaining] = useState(0);
|
|
const [codeUsed, setCodeUsed] = useState(false);
|
|
const [devices, setDevices] = useState<LinkedDevice[]>([]);
|
|
const [isLoadingDevices, setIsLoadingDevices] = useState(true);
|
|
const [copied, setCopied] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Redirect if not authenticated
|
|
useEffect(() => {
|
|
if (!authLoading && !isAuthenticated) {
|
|
router.push("/login");
|
|
}
|
|
}, [isAuthenticated, authLoading, router]);
|
|
|
|
// Load linked devices
|
|
const loadDevices = useCallback(async () => {
|
|
try {
|
|
const response = await api.request<LinkedDevice[]>("/device-link/devices");
|
|
setDevices(response);
|
|
} catch (err) {
|
|
console.error("Failed to load devices:", err);
|
|
} finally {
|
|
setIsLoadingDevices(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (isAuthenticated) {
|
|
loadDevices();
|
|
}
|
|
}, [isAuthenticated, loadDevices]);
|
|
|
|
// Generate a new link code
|
|
const generateCode = async () => {
|
|
setIsGenerating(true);
|
|
setError(null);
|
|
setCodeUsed(false);
|
|
|
|
try {
|
|
const response = await api.request<DeviceLinkCode>("/device-link/generate", {
|
|
method: "POST",
|
|
});
|
|
setLinkCode(response);
|
|
setTimeRemaining(response.expiresIn);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to generate code");
|
|
} finally {
|
|
setIsGenerating(false);
|
|
}
|
|
};
|
|
|
|
// Countdown timer
|
|
useEffect(() => {
|
|
if (timeRemaining <= 0 || codeUsed) return;
|
|
|
|
const timer = setInterval(() => {
|
|
setTimeRemaining((prev) => {
|
|
if (prev <= 1) {
|
|
clearInterval(timer);
|
|
return 0;
|
|
}
|
|
return prev - 1;
|
|
});
|
|
}, 1000);
|
|
|
|
return () => clearInterval(timer);
|
|
}, [timeRemaining, codeUsed]);
|
|
|
|
// Poll for code usage
|
|
useEffect(() => {
|
|
if (!linkCode || timeRemaining <= 0 || codeUsed) return;
|
|
|
|
const pollInterval = setInterval(async () => {
|
|
try {
|
|
const status = await api.request<{ status: string; deviceName?: string }>(
|
|
`/device-link/status/${linkCode.code}`
|
|
);
|
|
|
|
if (status.status === "used") {
|
|
setCodeUsed(true);
|
|
loadDevices(); // Refresh devices list
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to check code status:", err);
|
|
}
|
|
}, 2000);
|
|
|
|
return () => clearInterval(pollInterval);
|
|
}, [linkCode, timeRemaining, codeUsed, loadDevices]);
|
|
|
|
// Copy code to clipboard
|
|
const copyCode = () => {
|
|
if (linkCode) {
|
|
navigator.clipboard.writeText(linkCode.code);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
}
|
|
};
|
|
|
|
// Revoke a device
|
|
const revokeDevice = async (deviceId: string) => {
|
|
try {
|
|
await api.request(`/device-link/devices/${deviceId}`, {
|
|
method: "DELETE",
|
|
});
|
|
setDevices((prev) => prev.filter((d) => d.id !== deviceId));
|
|
} catch (err) {
|
|
console.error("Failed to revoke device:", err);
|
|
}
|
|
};
|
|
|
|
// Format time remaining
|
|
const formatTime = (seconds: number) => {
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = seconds % 60;
|
|
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
|
};
|
|
|
|
// Build QR code URL (contains code and server URL)
|
|
const getQRValue = () => {
|
|
if (!linkCode) return "";
|
|
const serverUrl = typeof window !== "undefined" ? window.location.origin : "";
|
|
return `lidify://link?code=${linkCode.code}&server=${encodeURIComponent(serverUrl)}`;
|
|
};
|
|
|
|
if (authLoading) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center">
|
|
<GradientSpinner size="md" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen relative pb-24">
|
|
{/* Header gradient */}
|
|
<div className="absolute inset-0 pointer-events-none">
|
|
<div className="absolute inset-0 bg-gradient-to-b from-purple-500/10 via-transparent to-transparent" style={{ height: "50vh" }} />
|
|
</div>
|
|
|
|
<div className="relative max-w-4xl mx-auto px-6 md:px-8 py-8">
|
|
{/* Title */}
|
|
<div className="mb-8">
|
|
<h1 className="text-3xl md:text-4xl font-black text-white mb-2">
|
|
Link Mobile Device
|
|
</h1>
|
|
<p className="text-gray-400">
|
|
Scan the QR code or enter the code in the Lidify app to link your device
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid md:grid-cols-2 gap-8">
|
|
{/* QR Code Section */}
|
|
<Card className="p-6 bg-white/[0.03] border border-white/5">
|
|
<h2 className="text-xl font-bold text-white mb-4 flex items-center gap-2">
|
|
<Smartphone className="w-5 h-5" />
|
|
Device Link Code
|
|
</h2>
|
|
|
|
{error && (
|
|
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm flex items-center gap-2">
|
|
<AlertCircle className="w-4 h-4" />
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{!linkCode && !isGenerating && (
|
|
<div className="text-center py-8">
|
|
<p className="text-gray-400 mb-4">
|
|
Generate a one-time code to link your mobile device
|
|
</p>
|
|
<button
|
|
onClick={generateCode}
|
|
className="px-6 py-3 bg-[#ecb200] hover:bg-[#d4a000] text-black font-medium rounded-full transition-all hover:scale-105"
|
|
>
|
|
Generate Code
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{isGenerating && (
|
|
<div className="flex items-center justify-center py-16">
|
|
<GradientSpinner size="md" />
|
|
</div>
|
|
)}
|
|
|
|
{linkCode && !isGenerating && (
|
|
<div className="text-center">
|
|
{codeUsed ? (
|
|
<div className="py-8">
|
|
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-green-500/20 flex items-center justify-center">
|
|
<Check className="w-8 h-8 text-green-400" />
|
|
</div>
|
|
<h3 className="text-xl font-bold text-white mb-2">
|
|
Device Linked!
|
|
</h3>
|
|
<p className="text-gray-400 mb-4">
|
|
Your device has been successfully connected
|
|
</p>
|
|
<button
|
|
onClick={generateCode}
|
|
className="px-4 py-2 bg-white/10 hover:bg-white/20 text-white rounded-full transition-all text-sm"
|
|
>
|
|
Link Another Device
|
|
</button>
|
|
</div>
|
|
) : timeRemaining <= 0 ? (
|
|
<div className="py-8">
|
|
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-red-500/20 flex items-center justify-center">
|
|
<Clock className="w-8 h-8 text-red-400" />
|
|
</div>
|
|
<h3 className="text-xl font-bold text-white mb-2">
|
|
Code Expired
|
|
</h3>
|
|
<p className="text-gray-400 mb-4">
|
|
Generate a new code to continue
|
|
</p>
|
|
<button
|
|
onClick={generateCode}
|
|
className="px-4 py-2 bg-[#ecb200] hover:bg-[#d4a000] text-black font-medium rounded-full transition-all"
|
|
>
|
|
<RefreshCw className="w-4 h-4 inline mr-2" />
|
|
Generate New Code
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* QR Code */}
|
|
<div className="bg-white p-4 rounded-xl inline-block mb-4">
|
|
<QRCodeSVG
|
|
value={getQRValue()}
|
|
size={180}
|
|
level="M"
|
|
includeMargin={false}
|
|
/>
|
|
</div>
|
|
|
|
{/* Code Display */}
|
|
<div className="mb-4">
|
|
<p className="text-gray-400 text-sm mb-2">
|
|
Or enter this code manually:
|
|
</p>
|
|
<div className="flex items-center justify-center gap-2">
|
|
<code className="text-3xl font-mono font-bold text-white tracking-widest bg-white/10 px-4 py-2 rounded-lg">
|
|
{linkCode.code}
|
|
</code>
|
|
<button
|
|
onClick={copyCode}
|
|
className={cn(
|
|
"p-2 rounded-lg transition-all",
|
|
copied
|
|
? "bg-green-500/20 text-green-400"
|
|
: "bg-white/10 hover:bg-white/20 text-gray-400"
|
|
)}
|
|
title="Copy code"
|
|
>
|
|
{copied ? (
|
|
<Check className="w-5 h-5" />
|
|
) : (
|
|
<Copy className="w-5 h-5" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Timer */}
|
|
<div className="flex items-center justify-center gap-2 text-gray-400">
|
|
<Clock className="w-4 h-4" />
|
|
<span>
|
|
Expires in {formatTime(timeRemaining)}
|
|
</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Linked Devices Section */}
|
|
<Card className="p-6 bg-white/[0.03] border border-white/5">
|
|
<h2 className="text-xl font-bold text-white mb-4">
|
|
Linked Devices
|
|
</h2>
|
|
|
|
{isLoadingDevices ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<GradientSpinner size="sm" />
|
|
</div>
|
|
) : devices.length === 0 ? (
|
|
<div className="text-center py-8 text-gray-400">
|
|
<Smartphone className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
|
<p>No devices linked yet</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{devices.map((device) => (
|
|
<div
|
|
key={device.id}
|
|
className="flex items-center justify-between p-3 bg-white/5 rounded-lg"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-full bg-purple-500/20 flex items-center justify-center">
|
|
<Smartphone className="w-5 h-5 text-purple-400" />
|
|
</div>
|
|
<div>
|
|
<p className="text-white font-medium">
|
|
{device.name}
|
|
</p>
|
|
<p className="text-gray-500 text-sm">
|
|
Last used:{" "}
|
|
{new Date(device.lastUsed).toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => revokeDevice(device.id)}
|
|
className="p-2 text-gray-400 hover:text-red-400 hover:bg-red-500/10 rounded-lg transition-all"
|
|
title="Revoke device"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Instructions */}
|
|
<Card className="mt-8 p-6 bg-white/[0.03] border border-white/5">
|
|
<h2 className="text-xl font-bold text-white mb-4">
|
|
How to Link Your Device
|
|
</h2>
|
|
<ol className="space-y-3 text-gray-400">
|
|
<li className="flex gap-3">
|
|
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-[#ecb200]/20 text-[#ecb200] text-sm font-bold flex items-center justify-center">
|
|
1
|
|
</span>
|
|
<span>Open the Lidify app on your mobile device</span>
|
|
</li>
|
|
<li className="flex gap-3">
|
|
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-[#ecb200]/20 text-[#ecb200] text-sm font-bold flex items-center justify-center">
|
|
2
|
|
</span>
|
|
<span>Tap "Scan QR Code" or "Enter Code" on the login screen</span>
|
|
</li>
|
|
<li className="flex gap-3">
|
|
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-[#ecb200]/20 text-[#ecb200] text-sm font-bold flex items-center justify-center">
|
|
3
|
|
</span>
|
|
<span>Scan the QR code above, or manually enter the 6-digit code</span>
|
|
</li>
|
|
<li className="flex gap-3">
|
|
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-[#ecb200]/20 text-[#ecb200] text-sm font-bold flex items-center justify-center">
|
|
4
|
|
</span>
|
|
<span>Your device will be automatically logged in and linked to your account</span>
|
|
</li>
|
|
</ol>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|