Add a temporary nsec login option, for testing
This commit is contained in:
parent
fa56d0252d
commit
fab4edd721
6 changed files with 277 additions and 17 deletions
|
|
@ -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;
|
||||||
signer = window.nostr ?? null;
|
const method = localStorage.getItem(METHOD_KEY) ?? "extension";
|
||||||
const { loadNostrUser } = await import("@nostr/gadgets/metadata");
|
|
||||||
user = await loadNostrUser(pubkey);
|
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;
|
||||||
|
}
|
||||||
|
await setUser(pubkey);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
174
src/lib/components/LoginModal.svelte
Normal file
174
src/lib/components/LoginModal.svelte
Normal 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}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue