first commit

Made-with: Cursor
This commit is contained in:
Michilis
2026-04-01 02:46:53 +00:00
commit 76210db03d
126 changed files with 20208 additions and 0 deletions

292
frontend/app/login/page.tsx Normal file
View File

@@ -0,0 +1,292 @@
"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 />
</>
);
}