293 lines
10 KiB
TypeScript
293 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { LogIn, Puzzle, Smartphone, RefreshCw, Link2 } from "lucide-react";
|
|
import { QRCodeSVG } from "qrcode.react";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { Navbar } from "@/components/public/Navbar";
|
|
import { Footer } from "@/components/public/Footer";
|
|
import {
|
|
generateNostrConnectSetup,
|
|
waitForNostrConnectSigner,
|
|
} from "@/lib/nostr";
|
|
|
|
type Tab = "extension" | "external";
|
|
|
|
export default function LoginPage() {
|
|
const { user, loading, login, loginWithBunker, loginWithConnectedSigner } =
|
|
useAuth();
|
|
const router = useRouter();
|
|
|
|
const [activeTab, setActiveTab] = useState<Tab>("extension");
|
|
const [error, setError] = useState("");
|
|
const [loggingIn, setLoggingIn] = useState(false);
|
|
|
|
// Extension tab
|
|
// (no extra state needed)
|
|
|
|
// External signer tab — QR section
|
|
const [qrUri, setQrUri] = useState<string | null>(null);
|
|
const [qrStatus, setQrStatus] = useState<
|
|
"generating" | "waiting" | "connecting"
|
|
>("generating");
|
|
const qrAbortRef = useRef<AbortController | null>(null);
|
|
const qrSecretRef = useRef<Uint8Array | null>(null);
|
|
|
|
// External signer tab — bunker URI section
|
|
const [bunkerInput, setBunkerInput] = useState("");
|
|
|
|
useEffect(() => {
|
|
if (!loading && user) redirectByRole(user.role);
|
|
}, [user, loading]);
|
|
|
|
function redirectByRole(role: string) {
|
|
if (role === "ADMIN" || role === "MODERATOR") {
|
|
router.push("/admin/overview");
|
|
} else {
|
|
router.push("/dashboard");
|
|
}
|
|
}
|
|
|
|
// Start (or restart) the nostrconnect QR flow
|
|
async function startQrFlow() {
|
|
qrAbortRef.current?.abort();
|
|
const controller = new AbortController();
|
|
qrAbortRef.current = controller;
|
|
|
|
setQrUri(null);
|
|
setQrStatus("generating");
|
|
setError("");
|
|
|
|
try {
|
|
const { uri, clientSecretKey } = await generateNostrConnectSetup();
|
|
if (controller.signal.aborted) return;
|
|
|
|
qrSecretRef.current = clientSecretKey;
|
|
setQrUri(uri);
|
|
setQrStatus("waiting");
|
|
|
|
const { signer } = await waitForNostrConnectSigner(
|
|
clientSecretKey,
|
|
uri,
|
|
controller.signal
|
|
);
|
|
if (controller.signal.aborted) return;
|
|
|
|
setQrStatus("connecting");
|
|
setLoggingIn(true);
|
|
const loggedInUser = await loginWithConnectedSigner(signer);
|
|
await signer.close().catch(() => {});
|
|
redirectByRole(loggedInUser.role);
|
|
} catch (err: any) {
|
|
if (controller.signal.aborted) return;
|
|
setError(err.message || "Connection failed");
|
|
setQrStatus("waiting");
|
|
setLoggingIn(false);
|
|
}
|
|
}
|
|
|
|
// Launch / restart QR when switching to external tab
|
|
useEffect(() => {
|
|
if (activeTab !== "external") {
|
|
qrAbortRef.current?.abort();
|
|
return;
|
|
}
|
|
startQrFlow();
|
|
return () => {
|
|
qrAbortRef.current?.abort();
|
|
};
|
|
}, [activeTab]);
|
|
|
|
const handleExtensionLogin = async () => {
|
|
setError("");
|
|
setLoggingIn(true);
|
|
try {
|
|
const loggedInUser = await login();
|
|
redirectByRole(loggedInUser.role);
|
|
} catch (err: any) {
|
|
setError(err.message || "Login failed");
|
|
} finally {
|
|
setLoggingIn(false);
|
|
}
|
|
};
|
|
|
|
const handleBunkerLogin = async () => {
|
|
if (!bunkerInput.trim()) return;
|
|
setError("");
|
|
setLoggingIn(true);
|
|
try {
|
|
const loggedInUser = await loginWithBunker(bunkerInput.trim());
|
|
redirectByRole(loggedInUser.role);
|
|
} catch (err: any) {
|
|
setError(err.message || "Connection failed");
|
|
} finally {
|
|
setLoggingIn(false);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<>
|
|
<Navbar />
|
|
<div className="flex items-center justify-center min-h-[60vh]">
|
|
<div className="text-on-surface/50">Loading...</div>
|
|
</div>
|
|
<Footer />
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (user) return null;
|
|
|
|
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
|
{ id: "extension", label: "Extension", icon: <Puzzle size={15} /> },
|
|
{ id: "external", label: "External Signer", icon: <Smartphone size={15} /> },
|
|
];
|
|
|
|
return (
|
|
<>
|
|
<Navbar />
|
|
<div className="flex items-center justify-center min-h-[70vh] px-8">
|
|
<div className="bg-surface-container-low rounded-xl p-8 max-w-md w-full">
|
|
{/* Header */}
|
|
<div className="text-center mb-6">
|
|
<div className="w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-4">
|
|
<LogIn size={26} className="text-primary" />
|
|
</div>
|
|
<h1 className="text-2xl font-bold text-on-surface mb-1">
|
|
Sign in to the Embassy
|
|
</h1>
|
|
<p className="text-on-surface/60 text-sm leading-relaxed">
|
|
Use your Nostr identity to access your dashboard.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Tab bar */}
|
|
<div className="flex rounded-lg bg-surface-container p-1 mb-6 gap-1">
|
|
{tabs.map((tab) => (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => {
|
|
setActiveTab(tab.id);
|
|
setError("");
|
|
}}
|
|
className={`flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-md text-sm font-medium transition-all ${
|
|
activeTab === tab.id
|
|
? "bg-surface-container-high text-on-surface shadow-sm"
|
|
: "text-on-surface/50 hover:text-on-surface/80"
|
|
}`}
|
|
>
|
|
{tab.icon}
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Extension tab */}
|
|
{activeTab === "extension" && (
|
|
<div className="space-y-4">
|
|
<button
|
|
onClick={handleExtensionLogin}
|
|
disabled={loggingIn}
|
|
className="w-full flex items-center justify-center gap-3 px-6 py-3.5 rounded-lg font-semibold transition-all bg-gradient-to-r from-primary to-primary-container text-on-primary hover:scale-105 active:opacity-80 disabled:opacity-50 disabled:hover:scale-100"
|
|
>
|
|
<LogIn size={20} />
|
|
{loggingIn ? "Connecting..." : "Login with Nostr"}
|
|
</button>
|
|
<p className="text-on-surface/40 text-xs text-center leading-relaxed">
|
|
Requires a Nostr browser extension such as Alby, nos2x, or
|
|
Flamingo. Your keys never leave your device.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* External signer tab */}
|
|
{activeTab === "external" && (
|
|
<div className="space-y-5">
|
|
{/* QR section */}
|
|
<div className="rounded-lg bg-surface-container p-4 flex flex-col items-center gap-3">
|
|
{qrStatus === "generating" || !qrUri ? (
|
|
<div className="w-[200px] h-[200px] flex items-center justify-center">
|
|
<div className="w-8 h-8 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
|
|
</div>
|
|
) : (
|
|
<div className="p-2 bg-white rounded-lg">
|
|
<QRCodeSVG value={qrUri} size={192} />
|
|
</div>
|
|
)}
|
|
|
|
<div className="text-center">
|
|
{qrStatus === "generating" && (
|
|
<p className="text-on-surface/50 text-xs">
|
|
Generating QR code…
|
|
</p>
|
|
)}
|
|
{qrStatus === "waiting" && (
|
|
<p className="text-on-surface/60 text-xs">
|
|
Scan with your signer app (e.g.{" "}
|
|
<span className="text-primary font-medium">Amber</span>)
|
|
</p>
|
|
)}
|
|
{qrStatus === "connecting" && (
|
|
<p className="text-on-surface/60 text-xs">
|
|
Signer connected — signing in…
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{qrStatus === "waiting" && qrUri && (
|
|
<button
|
|
onClick={() => startQrFlow()}
|
|
className="flex items-center gap-1.5 text-xs text-on-surface/40 hover:text-on-surface/70 transition-colors"
|
|
>
|
|
<RefreshCw size={12} />
|
|
Refresh QR
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Divider */}
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex-1 h-px bg-on-surface/10" />
|
|
<span className="text-on-surface/30 text-xs">or</span>
|
|
<div className="flex-1 h-px bg-on-surface/10" />
|
|
</div>
|
|
|
|
{/* Bunker URI input */}
|
|
<div className="space-y-2">
|
|
<label className="text-xs font-medium text-on-surface/60 flex items-center gap-1.5">
|
|
<Link2 size={12} />
|
|
Bunker URL
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={bunkerInput}
|
|
onChange={(e) => setBunkerInput(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && handleBunkerLogin()}
|
|
placeholder="bunker://..."
|
|
disabled={loggingIn}
|
|
className="w-full bg-surface-container rounded-lg px-3 py-2.5 text-sm text-on-surface placeholder:text-on-surface/30 border border-on-surface/10 focus:outline-none focus:border-primary/50 disabled:opacity-50"
|
|
/>
|
|
<button
|
|
onClick={handleBunkerLogin}
|
|
disabled={loggingIn || !bunkerInput.trim()}
|
|
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-all bg-primary/10 text-primary hover:bg-primary/20 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
>
|
|
{loggingIn ? "Connecting..." : "Connect"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Shared error */}
|
|
{error && (
|
|
<p className="mt-4 text-error text-sm text-center">{error}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<Footer />
|
|
</>
|
|
);
|
|
}
|