Files
grimoire/src/components/GlobalAuthPrompt.tsx
2025-12-16 17:28:50 +01:00

231 lines
6.8 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Key, X } from "lucide-react";
import { Button } from "./ui/button";
import { Checkbox } from "./ui/checkbox";
import { useRelayState } from "@/hooks/useRelayState";
import { RelayLink } from "./nostr/RelayLink";
interface AuthToastProps {
relayUrl: string;
challenge: string;
onAuthenticate: (remember: boolean) => Promise<void>;
onReject: (remember: boolean) => Promise<void>;
onDismiss: () => void;
}
function AuthToast({
relayUrl,
challenge,
onAuthenticate,
onReject,
onDismiss,
}: AuthToastProps) {
const [remember, setRemember] = useState(false);
const [loading, setLoading] = useState(false);
return (
<div className="bg-background border border-border shadow-lg p-4 min-w-[350px] max-w-[500px] overflow-hidden">
<div className="flex items-start gap-3">
<Key className="size-5 text-yellow-500 flex-shrink-0 mt-0.5" />
<div className="flex-1 space-y-3 min-w-0">
<div>
<div className="font-semibold text-sm text-foreground mb-1">
Authentication Request
</div>
<RelayLink
url={relayUrl}
showInboxOutbox={false}
variant="prompt"
iconClassname="size-4"
urlClassname="text-sm"
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id={`remember-${challenge}`}
checked={remember}
onCheckedChange={(checked) => setRemember(checked === true)}
/>
<label
htmlFor={`remember-${challenge}`}
className="text-xs text-muted-foreground cursor-pointer"
>
Remember my choice
</label>
</div>
<div className="flex gap-2">
<Button
size="sm"
onClick={async () => {
setLoading(true);
try {
await onAuthenticate(remember);
} finally {
setLoading(false);
}
}}
disabled={loading}
className="flex-1 bg-green-500 hover:bg-green-600 text-white h-8"
>
Yes
</Button>
<Button
size="sm"
variant="outline"
onClick={async () => {
setLoading(true);
try {
await onReject(remember);
} finally {
setLoading(false);
}
}}
disabled={loading}
className="flex-1 h-8"
>
No
</Button>
</div>
</div>
<button
onClick={onDismiss}
disabled={loading}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<X className="size-4" />
</button>
</div>
</div>
);
}
/**
* Global auth prompt using sonner toast - shows when any relay requests authentication
* Displays pending auth challenges as minimalistic toasts with infinite duration
*/
export function GlobalAuthPrompt() {
const {
pendingChallenges,
authenticateRelay,
rejectAuth,
setAuthPreference,
relays,
} = useRelayState();
const activeToasts = useRef<Map<string, string | number>>(new Map());
const [authenticatingRelays, setAuthenticatingRelays] = useState<Set<string>>(
new Set(),
);
// Watch for authentication success and show toast
useEffect(() => {
authenticatingRelays.forEach((relayUrl) => {
const relayState = relays[relayUrl];
if (relayState && relayState.authStatus === "authenticated") {
toast.success(`Authenticated with ${relayUrl}`, {
duration: 2500,
});
setAuthenticatingRelays((prev) => {
const next = new Set(prev);
next.delete(relayUrl);
return next;
});
}
});
}, [relays, authenticatingRelays]);
useEffect(() => {
// Show toasts for new challenges
pendingChallenges.forEach((challenge) => {
const key = challenge.relayUrl;
// Skip if we already have a toast for this relay
if (activeToasts.current.has(key)) {
return;
}
const toastId = toast.custom(
(t) => (
<AuthToast
relayUrl={challenge.relayUrl}
challenge={challenge.challenge}
onAuthenticate={async (remember) => {
if (remember) {
await setAuthPreference(challenge.relayUrl, "always");
}
activeToasts.current.delete(key);
toast.dismiss(t);
setAuthenticatingRelays((prev) =>
new Set(prev).add(challenge.relayUrl),
);
try {
await authenticateRelay(challenge.relayUrl);
} catch (error) {
console.error("Auth failed:", error);
setAuthenticatingRelays((prev) => {
const next = new Set(prev);
next.delete(challenge.relayUrl);
return next;
});
toast.error(
`Authentication failed: ${error instanceof Error ? error.message : "Unknown error"}`,
{
duration: 5000,
},
);
}
}}
onReject={async (remember) => {
if (remember) {
await setAuthPreference(challenge.relayUrl, "never");
}
rejectAuth(challenge.relayUrl, !remember);
activeToasts.current.delete(key);
toast.dismiss(t);
const message = remember
? `Will never prompt auth for ${challenge.relayUrl}`
: `Won't ask again this session for ${challenge.relayUrl}`;
toast.info(message, {
duration: 2000,
});
}}
onDismiss={() => {
rejectAuth(challenge.relayUrl, true);
activeToasts.current.delete(key);
toast.dismiss(t);
}}
/>
),
{
duration: Infinity,
position: "top-right",
},
);
activeToasts.current.set(key, toastId);
});
// Dismiss toasts for challenges that are no longer pending
activeToasts.current.forEach((toastId, relayUrl) => {
const stillPending = pendingChallenges.some(
(c) => c.relayUrl === relayUrl,
);
if (!stillPending) {
toast.dismiss(toastId);
activeToasts.current.delete(relayUrl);
}
});
}, [pendingChallenges, authenticateRelay, rejectAuth, setAuthPreference]);
return null; // No UI needed - toasts handle everything
}