Add a temporary nsec login option, for testing

This commit is contained in:
dtonon 2026-04-07 19:43:47 +01:00
parent fa56d0252d
commit fab4edd721
6 changed files with 277 additions and 17 deletions

View file

@ -1,5 +1,8 @@
import type { NostrUser } from "@nostr/gadgets/metadata"; import type { NostrUser } from "@nostr/gadgets/metadata";
import type { WindowNostr } from "@nostr/tools/nip07"; import type { WindowNostr } from "@nostr/tools/nip07";
import type { EventTemplate, VerifiedEvent } from "@nostr/tools/core";
import * as nip19 from "@nostr/tools/nip19";
import { finalizeEvent, getPublicKey } from "@nostr/tools/pure";
declare global { declare global {
interface Window { interface Window {
@ -7,8 +10,14 @@ declare global {
} }
} }
export type Signer = {
getPublicKey(): Promise<string>;
signEvent(template: EventTemplate): Promise<VerifiedEvent>;
};
let user = $state<NostrUser | null>(null); let user = $state<NostrUser | null>(null);
let signer = $state<WindowNostr | null>(null); let signer = $state<Signer | null>(null);
let loginModalOpen = $state(false);
export const auth = { export const auth = {
get user() { get user() {
@ -17,34 +26,107 @@ export const auth = {
get signer() { get signer() {
return signer; return signer;
}, },
get loginModalOpen() {
return loginModalOpen;
},
}; };
const PUBKEY_KEY = "nostr_pubkey"; const PUBKEY_KEY = "nostr_pubkey";
const METHOD_KEY = "nostr_login_method";
const NSEC_KEY = "nostr_nsec";
export async function login() { export function openLogin() {
loginModalOpen = true;
}
export function closeLogin() {
loginModalOpen = false;
}
function makeNsecSigner(secretKey: Uint8Array): Signer {
const pubkey = getPublicKey(secretKey);
return {
async getPublicKey() {
return pubkey;
},
async signEvent(template) {
return finalizeEvent(template, secretKey);
},
};
}
async function setUser(pubkey: string) {
const { loadNostrUser } = await import("@nostr/gadgets/metadata");
user = await loadNostrUser(pubkey);
}
export async function loginWithExtension() {
if (!window.nostr) { if (!window.nostr) {
alert( throw new Error("No Nostr extension found");
"No Nostr extension found. Please install a Nostr extension like nos2x.",
);
return;
} }
const pubkey = await window.nostr.getPublicKey(); const pubkey = await window.nostr.getPublicKey();
signer = window.nostr; signer = window.nostr;
localStorage.setItem(PUBKEY_KEY, pubkey); localStorage.setItem(PUBKEY_KEY, pubkey);
const { loadNostrUser } = await import("@nostr/gadgets/metadata"); localStorage.setItem(METHOD_KEY, "extension");
user = await loadNostrUser(pubkey); localStorage.removeItem(NSEC_KEY);
await setUser(pubkey);
}
function parseSecretKey(input: string): Uint8Array {
const trimmed = input.trim();
if (/^[0-9a-fA-F]{64}$/.test(trimmed)) {
const sk = new Uint8Array(32);
for (let i = 0; i < 32; i++) {
sk[i] = parseInt(trimmed.slice(i * 2, i * 2 + 2), 16);
}
return sk;
}
let decoded;
try {
decoded = nip19.decode(trimmed);
} catch {
throw new Error("Invalid nsec");
}
if (decoded.type !== "nsec") throw new Error("Invalid nsec");
return decoded.data;
}
export async function loginWithNsec(input: string) {
const sk = parseSecretKey(input);
const nsec = nip19.nsecEncode(sk);
const pubkey = getPublicKey(sk);
signer = makeNsecSigner(sk);
localStorage.setItem(PUBKEY_KEY, pubkey);
localStorage.setItem(METHOD_KEY, "nsec");
localStorage.setItem(NSEC_KEY, nsec);
await setUser(pubkey);
} }
export function logout() { export function logout() {
user = null; user = null;
signer = null; signer = null;
localStorage.removeItem(PUBKEY_KEY); localStorage.removeItem(PUBKEY_KEY);
localStorage.removeItem(METHOD_KEY);
localStorage.removeItem(NSEC_KEY);
} }
export async function restoreSession() { export async function restoreSession() {
const pubkey = localStorage.getItem(PUBKEY_KEY); const pubkey = localStorage.getItem(PUBKEY_KEY);
if (!pubkey) return; if (!pubkey) return;
const method = localStorage.getItem(METHOD_KEY) ?? "extension";
if (method === "nsec") {
const nsec = localStorage.getItem(NSEC_KEY);
if (!nsec) return;
try {
const decoded = nip19.decode(nsec);
if (decoded.type !== "nsec") return;
signer = makeNsecSigner(decoded.data);
} catch {
return;
}
} else {
signer = window.nostr ?? null; signer = window.nostr ?? null;
const { loadNostrUser } = await import("@nostr/gadgets/metadata"); }
user = await loadNostrUser(pubkey); await setUser(pubkey);
} }

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { rooms } from "$lib/mock"; import { rooms } from "$lib/mock";
import { auth, login, logout } from "$lib/auth.svelte"; import { auth, openLogin, logout } from "$lib/auth.svelte";
import { groupStore } from "$lib/group.svelte"; import { groupStore } from "$lib/group.svelte";
type Props = { type Props = {
@ -94,7 +94,7 @@
</button> </button>
{:else} {:else}
<button <button
onclick={login} onclick={openLogin}
class="mt-3 w-full rounded bg-brand px-3 py-1.5 font-medium text-sm text-white hover:bg-brand-hover" class="mt-3 w-full rounded bg-brand px-3 py-1.5 font-medium text-sm text-white hover:bg-brand-hover"
> >
Login Login

View file

@ -0,0 +1,174 @@
<script lang="ts">
import {
auth,
closeLogin,
loginWithExtension,
loginWithNsec,
} from "$lib/auth.svelte";
import { tick } from "svelte";
let view = $state<"extension" | "nsec">("extension");
let nsec = $state("");
let error = $state<string | null>(null);
let busy = $state(false);
let nsecInput = $state<HTMLInputElement | null>(null);
const hasExtension = $derived(
typeof window !== "undefined" && !!window.nostr,
);
function reset() {
view = "extension";
nsec = "";
error = null;
}
async function handleExtension() {
if (busy) return;
error = null;
busy = true;
try {
await loginWithExtension();
closeLogin();
reset();
} catch (e) {
error = e instanceof Error ? e.message : "Failed to login";
} finally {
busy = false;
}
}
async function handleNsec() {
if (busy || !nsec.trim()) return;
error = null;
busy = true;
try {
await loginWithNsec(nsec);
closeLogin();
reset();
} catch (e) {
error = e instanceof Error ? e.message : "Failed to login";
} finally {
busy = false;
}
}
function onClose() {
if (busy) return;
closeLogin();
reset();
}
async function showNsecView() {
view = "nsec";
error = null;
await tick();
nsecInput?.focus();
}
function showExtensionView() {
view = "extension";
error = null;
}
function onKeydown(e: KeyboardEvent) {
if (!auth.loginModalOpen) return;
if (e.key === "Escape") onClose();
}
</script>
<svelte:window onkeydown={onKeydown} />
{#if auth.loginModalOpen}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<button
type="button"
aria-label="Close login"
class="absolute inset-0 bg-black/40"
onclick={onClose}
></button>
<div
class="relative w-full max-w-sm rounded-lg bg-white p-6 shadow-xl"
role="dialog"
aria-modal="true"
aria-labelledby="login-title"
>
<button
type="button"
onclick={onClose}
aria-label="Close"
class="absolute right-3 top-3 text-2xl leading-none text-gray-400 hover:text-gray-700"
>
×
</button>
<h2 id="login-title" class="mb-4 text-lg font-semibold text-gray-900">
{view === "extension" ? "Log in" : "Log in with nsec"}
</h2>
{#if error}
<div
class="mb-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"
role="alert"
>
{error}
</div>
{/if}
{#if view === "extension"}
<button
onclick={handleExtension}
disabled={busy || !hasExtension}
class="w-full rounded bg-brand px-3 py-2 text-sm font-medium text-white hover:bg-brand-hover disabled:cursor-not-allowed disabled:opacity-50"
>
{busy ? "Connecting…" : "Log in with extension"}
</button>
{#if !hasExtension}
<p class="mt-2 text-xs text-gray-500">
No Nostr extension detected in this browser.
</p>
{/if}
<button
type="button"
onclick={showNsecView}
class="mt-4 block w-full text-center text-sm text-brand hover:underline"
>
Or log in using your nsec
</button>
{:else}
<label for="nsec-input" class="sr-only">nsec</label>
<input
id="nsec-input"
bind:this={nsecInput}
type="password"
placeholder="nsec1…"
bind:value={nsec}
disabled={busy}
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
onkeydown={(e) => e.key === "Enter" && handleNsec()}
class="w-full rounded border border-gray-200 px-3 py-2 font-mono text-sm focus:outline-none focus:ring-1 focus:ring-brand disabled:opacity-50"
/>
<button
onclick={handleNsec}
disabled={busy || !nsec.trim()}
class="mt-3 w-full rounded bg-brand px-3 py-2 text-sm font-medium text-white hover:bg-brand-hover disabled:cursor-not-allowed disabled:opacity-50"
>
{busy ? "Logging in…" : "Log in"}
</button>
<p class="mt-2 text-xs text-gray-500">
Your key is kept in this browser. Use only for testing.
</p>
<button
type="button"
onclick={showExtensionView}
class="mt-4 block w-full text-center text-sm text-brand hover:underline"
>
Log in with extension
</button>
{/if}
</div>
</div>
{/if}

View file

@ -2,6 +2,7 @@ import { SimplePool } from "@nostr/tools";
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata"; import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
import { RELAY_URL, GROUP_ID } from "$lib/config"; import { RELAY_URL, GROUP_ID } from "$lib/config";
import { threads as mockThreads } from "$lib/mock"; import { threads as mockThreads } from "$lib/mock";
import { auth } from "$lib/auth.svelte";
const isNostrId = (id: string) => /^[0-9a-f]{64}$/.test(id); const isNostrId = (id: string) => /^[0-9a-f]{64}$/.test(id);
@ -98,7 +99,7 @@ export async function loadThread(id: string) {
export async function sendReply(content: string, ownPubkey: string) { export async function sendReply(content: string, ownPubkey: string) {
if (!detail) throw new Error("No thread loaded"); if (!detail) throw new Error("No thread loaded");
if (!window.nostr) throw new Error("No Nostr extension found"); if (!auth.signer) throw new Error("Not logged in");
// Use last 3 events not authored by us as previous refs (NIP-29) // Use last 3 events not authored by us as previous refs (NIP-29)
const previousRefs = [...detail.replies, detail.op] const previousRefs = [...detail.replies, detail.op]
@ -114,7 +115,7 @@ export async function sendReply(content: string, ownPubkey: string) {
if (previousRefs.length > 0) tags.push(["previous", ...previousRefs]); if (previousRefs.length > 0) tags.push(["previous", ...previousRefs]);
console.log("[reply] signing event…"); console.log("[reply] signing event…");
const signed = await window.nostr.signEvent({ const signed = await auth.signer.signEvent({
kind: 1111, kind: 1111,
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
tags, tags,

View file

@ -4,6 +4,7 @@
import Navbar from "$lib/components/Navbar.svelte"; import Navbar from "$lib/components/Navbar.svelte";
import LeftSidebar from "$lib/components/LeftSidebar.svelte"; import LeftSidebar from "$lib/components/LeftSidebar.svelte";
import ChatSidebar from "$lib/components/ChatSidebar.svelte"; import ChatSidebar from "$lib/components/ChatSidebar.svelte";
import LoginModal from "$lib/components/LoginModal.svelte";
import { page } from "$app/state"; import { page } from "$app/state";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { restoreSession } from "$lib/auth.svelte"; import { restoreSession } from "$lib/auth.svelte";
@ -45,3 +46,5 @@
{/if} {/if}
</div> </div>
</div> </div>
<LoginModal />

View file

@ -7,7 +7,7 @@
sendReply, sendReply,
type PostData, type PostData,
} from "$lib/thread.svelte"; } from "$lib/thread.svelte";
import { auth, login } from "$lib/auth.svelte"; import { auth, openLogin } from "$lib/auth.svelte";
import Reactions from "$lib/components/Reactions.svelte"; import Reactions from "$lib/components/Reactions.svelte";
import ThreadScrubber from "$lib/components/ThreadScrubber.svelte"; import ThreadScrubber from "$lib/components/ThreadScrubber.svelte";
import type { NostrUser } from "@nostr/gadgets/metadata"; import type { NostrUser } from "@nostr/gadgets/metadata";
@ -200,7 +200,7 @@
{:else} {:else}
<p class=" text-gray-500 text-center"> <p class=" text-gray-500 text-center">
To participate and reply, please To participate and reply, please
<button onclick={login} class="text-brand hover:underline" <button onclick={openLogin} class="text-brand hover:underline"
>login now</button >login now</button
> >
</p> </p>