Implement login via bunker
This commit is contained in:
parent
f734ff030c
commit
03395adf85
7 changed files with 382 additions and 13 deletions
|
|
@ -12,6 +12,7 @@ PUBLIC_SSR_CACHE_STALE=21600
|
||||||
|
|
||||||
PUBLIC_SEARCH=no # yes | no — enable the search box (requires a relay with NIP-50 support)
|
PUBLIC_SEARCH=no # yes | no — enable the search box (requires a relay with NIP-50 support)
|
||||||
PUBLIC_LABELS= # comma-separated discussion labels (e.g., bug,feature,question)
|
PUBLIC_LABELS= # comma-separated discussion labels (e.g., bug,feature,question)
|
||||||
|
PUBLIC_NOSTRCONNECT_RELAYS= # comma-separated relays for nostrconnect:// (bunker) login; defaults to bucket.coracle.social,relay.primal.net
|
||||||
PUBLIC_BLOSSOM_URL= # Blossom server URL for uploads (e.g., https://blossom.primal.net)
|
PUBLIC_BLOSSOM_URL= # Blossom server URL for uploads (e.g., https://blossom.primal.net)
|
||||||
# Theme color overrides — quote the value, a leading # is read as a comment otherwise
|
# Theme color overrides — quote the value, a leading # is read as a comment otherwise
|
||||||
PUBLIC_ACCENT_COLOR= # override the accent color (default #e32a6d), e.g. "#00ff00"; hover shade derived
|
PUBLIC_ACCENT_COLOR= # override the accent color (default #e32a6d), e.g. "#00ff00"; hover shade derived
|
||||||
|
|
|
||||||
9
package-lock.json
generated
9
package-lock.json
generated
|
|
@ -10,7 +10,8 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@djot/djot": "^0.3.2",
|
"@djot/djot": "^0.3.2",
|
||||||
"@nostr/gadgets": "npm:@jsr/nostr__gadgets@^0.1.8",
|
"@nostr/gadgets": "npm:@jsr/nostr__gadgets@^0.1.8",
|
||||||
"@nostr/tools": "npm:@jsr/nostr__tools@^2.23.3"
|
"@nostr/tools": "npm:@jsr/nostr__tools@^2.23.3",
|
||||||
|
"uqr": "^0.1.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^2.0.4",
|
"@eslint/compat": "^2.0.4",
|
||||||
|
|
@ -4022,6 +4023,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/uqr": {
|
||||||
|
"version": "0.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.3.tgz",
|
||||||
|
"integrity": "sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/uri-js": {
|
"node_modules/uri-js": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,8 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@djot/djot": "^0.3.2",
|
"@djot/djot": "^0.3.2",
|
||||||
"@nostr/gadgets": "npm:@jsr/nostr__gadgets@^0.1.8",
|
"@nostr/gadgets": "npm:@jsr/nostr__gadgets@^0.1.8",
|
||||||
"@nostr/tools": "npm:@jsr/nostr__tools@^2.23.3"
|
"@nostr/tools": "npm:@jsr/nostr__tools@^2.23.3",
|
||||||
|
"uqr": "^0.1.3"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"lightningcss": "^1.33.0"
|
"lightningcss": "^1.33.0"
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,14 @@ import type { EventTemplate, VerifiedEvent } from "@nostr/tools/core";
|
||||||
import * as nip19 from "@nostr/tools/nip19";
|
import * as nip19 from "@nostr/tools/nip19";
|
||||||
import { finalizeEvent, getPublicKey } from "@nostr/tools/pure";
|
import { finalizeEvent, getPublicKey } from "@nostr/tools/pure";
|
||||||
import { resetJoinState, initJoinForUser } from "$lib/join.svelte";
|
import { resetJoinState, initJoinForUser } from "$lib/join.svelte";
|
||||||
|
import {
|
||||||
|
connectBunker,
|
||||||
|
restoreBunker,
|
||||||
|
startNostrConnect,
|
||||||
|
type BunkerSession,
|
||||||
|
type NostrConnect,
|
||||||
|
} from "$lib/bunker";
|
||||||
|
import type { BunkerSigner } from "@nostr/tools/nip46";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
|
|
@ -18,6 +26,8 @@ export type Signer = {
|
||||||
|
|
||||||
let user = $state<NostrUser | null>(null);
|
let user = $state<NostrUser | null>(null);
|
||||||
let signer = $state<Signer | null>(null);
|
let signer = $state<Signer | null>(null);
|
||||||
|
// Kept outside $state (see makeExtensionSigner) so it can be closed on logout
|
||||||
|
let bunker: BunkerSigner | null = null;
|
||||||
let loginModalOpen = $state(false);
|
let loginModalOpen = $state(false);
|
||||||
// Optional action to run once login succeeds, so an intent like "post" started
|
// Optional action to run once login succeeds, so an intent like "post" started
|
||||||
// while logged out resumes (login => join => composer) instead of being dropped.
|
// while logged out resumes (login => join => composer) instead of being dropped.
|
||||||
|
|
@ -45,6 +55,8 @@ export const auth = {
|
||||||
const PUBKEY_KEY = "nostr_pubkey";
|
const PUBKEY_KEY = "nostr_pubkey";
|
||||||
const METHOD_KEY = "nostr_login_method";
|
const METHOD_KEY = "nostr_login_method";
|
||||||
const NSEC_KEY = "nostr_nsec";
|
const NSEC_KEY = "nostr_nsec";
|
||||||
|
const BUNKER_KEY = "nostr_bunker";
|
||||||
|
const BUNKER_CLIENT_KEY = "nostr_bunker_client_key";
|
||||||
|
|
||||||
export function openLogin(after?: () => void) {
|
export function openLogin(after?: () => void) {
|
||||||
afterLogin = after ?? null;
|
afterLogin = after ?? null;
|
||||||
|
|
@ -94,6 +106,30 @@ function makeExtensionSigner(): Signer {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeBunkerSigner(remote: BunkerSigner, pubkey: string): Signer {
|
||||||
|
return {
|
||||||
|
async getPublicKey() {
|
||||||
|
return pubkey;
|
||||||
|
},
|
||||||
|
async signEvent(template) {
|
||||||
|
return remote.signEvent(template);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeBunker() {
|
||||||
|
bunker?.close().catch(() => {});
|
||||||
|
bunker = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearStoredSession() {
|
||||||
|
localStorage.removeItem(PUBKEY_KEY);
|
||||||
|
localStorage.removeItem(METHOD_KEY);
|
||||||
|
localStorage.removeItem(NSEC_KEY);
|
||||||
|
localStorage.removeItem(BUNKER_KEY);
|
||||||
|
localStorage.removeItem(BUNKER_CLIENT_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
async function setUser(pubkey: string) {
|
async function setUser(pubkey: string) {
|
||||||
const { loadNostrUser } = await import("@nostr/gadgets/metadata");
|
const { loadNostrUser } = await import("@nostr/gadgets/metadata");
|
||||||
user = await loadNostrUser(pubkey);
|
user = await loadNostrUser(pubkey);
|
||||||
|
|
@ -105,10 +141,11 @@ export async function loginWithExtension() {
|
||||||
throw new Error("No Nostr extension found");
|
throw new Error("No Nostr extension found");
|
||||||
}
|
}
|
||||||
const pubkey = await window.nostr.getPublicKey();
|
const pubkey = await window.nostr.getPublicKey();
|
||||||
|
closeBunker();
|
||||||
signer = makeExtensionSigner();
|
signer = makeExtensionSigner();
|
||||||
|
clearStoredSession();
|
||||||
localStorage.setItem(PUBKEY_KEY, pubkey);
|
localStorage.setItem(PUBKEY_KEY, pubkey);
|
||||||
localStorage.setItem(METHOD_KEY, "extension");
|
localStorage.setItem(METHOD_KEY, "extension");
|
||||||
localStorage.removeItem(NSEC_KEY);
|
|
||||||
await setUser(pubkey);
|
await setUser(pubkey);
|
||||||
sessionEpoch++;
|
sessionEpoch++;
|
||||||
runAfterLogin();
|
runAfterLogin();
|
||||||
|
|
@ -137,7 +174,9 @@ export async function loginWithNsec(input: string) {
|
||||||
const sk = parseSecretKey(input);
|
const sk = parseSecretKey(input);
|
||||||
const nsec = nip19.nsecEncode(sk);
|
const nsec = nip19.nsecEncode(sk);
|
||||||
const pubkey = getPublicKey(sk);
|
const pubkey = getPublicKey(sk);
|
||||||
|
closeBunker();
|
||||||
signer = makeNsecSigner(sk);
|
signer = makeNsecSigner(sk);
|
||||||
|
clearStoredSession();
|
||||||
localStorage.setItem(PUBKEY_KEY, pubkey);
|
localStorage.setItem(PUBKEY_KEY, pubkey);
|
||||||
localStorage.setItem(METHOD_KEY, "nsec");
|
localStorage.setItem(METHOD_KEY, "nsec");
|
||||||
localStorage.setItem(NSEC_KEY, nsec);
|
localStorage.setItem(NSEC_KEY, nsec);
|
||||||
|
|
@ -146,12 +185,45 @@ export async function loginWithNsec(input: string) {
|
||||||
runAfterLogin();
|
runAfterLogin();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loginWithBunkerSession(session: BunkerSession) {
|
||||||
|
closeBunker();
|
||||||
|
bunker = session.signer;
|
||||||
|
signer = makeBunkerSigner(session.signer, session.pubkey);
|
||||||
|
clearStoredSession();
|
||||||
|
localStorage.setItem(PUBKEY_KEY, session.pubkey);
|
||||||
|
localStorage.setItem(METHOD_KEY, "bunker");
|
||||||
|
localStorage.setItem(BUNKER_KEY, session.bunkerUrl);
|
||||||
|
localStorage.setItem(BUNKER_CLIENT_KEY, session.clientSecretKey);
|
||||||
|
await setUser(session.pubkey);
|
||||||
|
sessionEpoch++;
|
||||||
|
runAfterLogin();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pasted bunker:// URL or a NIP-05 that advertises one
|
||||||
|
export async function loginWithBunker(input: string) {
|
||||||
|
await loginWithBunkerSession(await connectBunker(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client-initiated flow: show `uri` (QR/link) and await `done`. `cancel` stops
|
||||||
|
// waiting when the user leaves the view.
|
||||||
|
export function loginWithNostrConnect(): {
|
||||||
|
uri: string;
|
||||||
|
done: Promise<void>;
|
||||||
|
cancel: () => void;
|
||||||
|
} {
|
||||||
|
const nc: NostrConnect = startNostrConnect();
|
||||||
|
return {
|
||||||
|
uri: nc.uri,
|
||||||
|
done: nc.session.then(loginWithBunkerSession),
|
||||||
|
cancel: nc.cancel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function logout() {
|
export function logout() {
|
||||||
user = null;
|
user = null;
|
||||||
signer = null;
|
signer = null;
|
||||||
localStorage.removeItem(PUBKEY_KEY);
|
closeBunker();
|
||||||
localStorage.removeItem(METHOD_KEY);
|
clearStoredSession();
|
||||||
localStorage.removeItem(NSEC_KEY);
|
|
||||||
resetJoinState();
|
resetJoinState();
|
||||||
sessionEpoch++;
|
sessionEpoch++;
|
||||||
}
|
}
|
||||||
|
|
@ -171,6 +243,16 @@ export async function restoreSession() {
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
} else if (method === "bunker") {
|
||||||
|
const url = localStorage.getItem(BUNKER_KEY);
|
||||||
|
const clientKey = localStorage.getItem(BUNKER_CLIENT_KEY);
|
||||||
|
if (!url || !clientKey) return;
|
||||||
|
try {
|
||||||
|
bunker = await restoreBunker(url, clientKey);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
signer = makeBunkerSigner(bunker, pubkey);
|
||||||
} else {
|
} else {
|
||||||
signer = makeExtensionSigner();
|
signer = makeExtensionSigner();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
100
src/lib/bunker.ts
Normal file
100
src/lib/bunker.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import {
|
||||||
|
BunkerSigner,
|
||||||
|
createNostrConnectURI,
|
||||||
|
parseBunkerInput,
|
||||||
|
toBunkerURL,
|
||||||
|
} from "@nostr/tools/nip46";
|
||||||
|
import { generateSecretKey, getPublicKey } from "@nostr/tools/pure";
|
||||||
|
import { bytesToHex, hexToBytes } from "@nostr/tools/utils";
|
||||||
|
import { NOSTRCONNECT_RELAYS } from "$lib/config";
|
||||||
|
|
||||||
|
// NIP-46 remote signing. Two ways in: the user pastes a bunker:// URL (or a
|
||||||
|
// NIP-05 that advertises one), or the app shows a nostrconnect:// URI and the
|
||||||
|
// signer app calls back. Either way the result is a live BunkerSigner plus the
|
||||||
|
// data needed to reconnect silently next time: the bunker URL (secret
|
||||||
|
// stripped) and the client key the conversation is bound to.
|
||||||
|
|
||||||
|
export type BunkerSession = {
|
||||||
|
signer: BunkerSigner;
|
||||||
|
pubkey: string;
|
||||||
|
bunkerUrl: string;
|
||||||
|
clientSecretKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const signerParams = {
|
||||||
|
onauth: (url: string) => {
|
||||||
|
window.open(url, "_blank");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function stripSecret(url: string): string {
|
||||||
|
const u = new URL(url);
|
||||||
|
u.searchParams.delete("secret");
|
||||||
|
return u.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connectBunker(input: string): Promise<BunkerSession> {
|
||||||
|
const bp = await parseBunkerInput(input.trim());
|
||||||
|
if (!bp) throw new Error("Invalid bunker");
|
||||||
|
if (bp.relays.length === 0) throw new Error("Bunker URL has no relay");
|
||||||
|
const sk = generateSecretKey();
|
||||||
|
const signer = BunkerSigner.fromBunker(sk, bp, signerParams);
|
||||||
|
try {
|
||||||
|
await signer.connect();
|
||||||
|
const pubkey = await signer.getPublicKey();
|
||||||
|
return {
|
||||||
|
signer,
|
||||||
|
pubkey,
|
||||||
|
bunkerUrl: stripSecret(toBunkerURL(bp)),
|
||||||
|
clientSecretKey: bytesToHex(sk),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
signer.close().catch(() => {});
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NostrConnect = {
|
||||||
|
uri: string;
|
||||||
|
// Resolves once a signer answers the URI; rejects on abort or relay close
|
||||||
|
session: Promise<BunkerSession>;
|
||||||
|
cancel: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function startNostrConnect(): NostrConnect {
|
||||||
|
const sk = generateSecretKey();
|
||||||
|
const uri = createNostrConnectURI({
|
||||||
|
clientPubkey: getPublicKey(sk),
|
||||||
|
relays: NOSTRCONNECT_RELAYS,
|
||||||
|
secret: Math.random().toString(36).substring(2, 10),
|
||||||
|
name: location.host,
|
||||||
|
url: location.origin,
|
||||||
|
});
|
||||||
|
const abort = new AbortController();
|
||||||
|
const session = BunkerSigner.fromURI(
|
||||||
|
sk,
|
||||||
|
uri,
|
||||||
|
signerParams,
|
||||||
|
abort.signal,
|
||||||
|
).then(async (signer) => {
|
||||||
|
const pubkey = await signer.getPublicKey();
|
||||||
|
return {
|
||||||
|
signer,
|
||||||
|
pubkey,
|
||||||
|
bunkerUrl: stripSecret(toBunkerURL(signer.bp)),
|
||||||
|
clientSecretKey: bytesToHex(sk),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { uri, session, cancel: () => abort.abort() };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuilds the signer from stored data without a connect round-trip: the
|
||||||
|
// bunker already knows this client key, so the first sign request is enough.
|
||||||
|
export async function restoreBunker(
|
||||||
|
bunkerUrl: string,
|
||||||
|
clientSecretKey: string,
|
||||||
|
): Promise<BunkerSigner> {
|
||||||
|
const bp = await parseBunkerInput(bunkerUrl);
|
||||||
|
if (!bp) throw new Error("Invalid bunker");
|
||||||
|
return BunkerSigner.fromBunker(hexToBytes(clientSecretKey), bp, signerParams);
|
||||||
|
}
|
||||||
|
|
@ -2,25 +2,53 @@
|
||||||
import {
|
import {
|
||||||
auth,
|
auth,
|
||||||
closeLogin,
|
closeLogin,
|
||||||
|
loginWithBunker,
|
||||||
loginWithExtension,
|
loginWithExtension,
|
||||||
|
loginWithNostrConnect,
|
||||||
loginWithNsec,
|
loginWithNsec,
|
||||||
} from "$lib/auth.svelte";
|
} from "$lib/auth.svelte";
|
||||||
import { tick } from "svelte";
|
import { onDestroy, tick } from "svelte";
|
||||||
|
import { renderSVG } from "uqr";
|
||||||
|
|
||||||
let view = $state<"extension" | "nsec">("extension");
|
type View = "extension" | "bunker" | "nsec";
|
||||||
|
|
||||||
|
let view = $state<View>("extension");
|
||||||
let nsec = $state("");
|
let nsec = $state("");
|
||||||
|
let bunkerUrl = $state("");
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
|
let copied = $state(false);
|
||||||
let nsecInput = $state<HTMLInputElement | null>(null);
|
let nsecInput = $state<HTMLInputElement | null>(null);
|
||||||
|
let bunkerInput = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
// Client-initiated NIP-46 flow, alive only while the bunker view is shown
|
||||||
|
let connect = $state<ReturnType<typeof loginWithNostrConnect> | null>(null);
|
||||||
|
const qrSvg = $derived(
|
||||||
|
connect ? renderSVG(connect.uri, { pixelSize: 4, border: 2 }) : "",
|
||||||
|
);
|
||||||
|
|
||||||
const hasExtension = $derived(
|
const hasExtension = $derived(
|
||||||
typeof window !== "undefined" && !!window.nostr,
|
typeof window !== "undefined" && !!window.nostr,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const titles: Record<View, string> = {
|
||||||
|
extension: "Log in",
|
||||||
|
bunker: "Log in with a bunker",
|
||||||
|
nsec: "Log in with nsec",
|
||||||
|
};
|
||||||
|
|
||||||
|
function stopConnect() {
|
||||||
|
connect?.cancel();
|
||||||
|
connect = null;
|
||||||
|
}
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
|
stopConnect();
|
||||||
view = "extension";
|
view = "extension";
|
||||||
nsec = "";
|
nsec = "";
|
||||||
|
bunkerUrl = "";
|
||||||
error = null;
|
error = null;
|
||||||
|
copied = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleExtension() {
|
async function handleExtension() {
|
||||||
|
|
@ -53,6 +81,50 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleBunker() {
|
||||||
|
if (busy || !bunkerUrl.trim()) return;
|
||||||
|
error = null;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
await loginWithBunker(bunkerUrl);
|
||||||
|
closeLogin();
|
||||||
|
reset();
|
||||||
|
} catch (e) {
|
||||||
|
error =
|
||||||
|
e instanceof Error ? e.message : "Failed to connect to the bunker";
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startConnect() {
|
||||||
|
stopConnect();
|
||||||
|
const nc = loginWithNostrConnect();
|
||||||
|
connect = nc;
|
||||||
|
nc.done
|
||||||
|
.then(() => {
|
||||||
|
if (connect !== nc) return;
|
||||||
|
closeLogin();
|
||||||
|
reset();
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (connect !== nc) return; // cancelled by leaving the view
|
||||||
|
connect = null;
|
||||||
|
error = `${e instanceof Error ? e.message : "Connection failed"}. Go back and retry.`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyUri() {
|
||||||
|
if (!connect) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(connect.uri);
|
||||||
|
copied = true;
|
||||||
|
setTimeout(() => (copied = false), 2000);
|
||||||
|
} catch {
|
||||||
|
error = "Could not copy to the clipboard";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onClose() {
|
function onClose() {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
closeLogin();
|
closeLogin();
|
||||||
|
|
@ -60,13 +132,23 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showNsecView() {
|
async function showNsecView() {
|
||||||
|
stopConnect();
|
||||||
view = "nsec";
|
view = "nsec";
|
||||||
error = null;
|
error = null;
|
||||||
await tick();
|
await tick();
|
||||||
nsecInput?.focus();
|
nsecInput?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function showBunkerView() {
|
||||||
|
view = "bunker";
|
||||||
|
error = null;
|
||||||
|
startConnect();
|
||||||
|
await tick();
|
||||||
|
bunkerInput?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
function showExtensionView() {
|
function showExtensionView() {
|
||||||
|
stopConnect();
|
||||||
view = "extension";
|
view = "extension";
|
||||||
error = null;
|
error = null;
|
||||||
}
|
}
|
||||||
|
|
@ -75,6 +157,8 @@
|
||||||
if (!auth.loginModalOpen) return;
|
if (!auth.loginModalOpen) return;
|
||||||
if (e.key === "Escape") onClose();
|
if (e.key === "Escape") onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onDestroy(stopConnect);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onkeydown={onKeydown} />
|
<svelte:window onkeydown={onKeydown} />
|
||||||
|
|
@ -88,7 +172,7 @@
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
></button>
|
></button>
|
||||||
<div
|
<div
|
||||||
class="relative w-full max-w-sm rounded-lg bg-white dark:bg-neutral-900 p-6 shadow-xl"
|
class="relative w-full max-w-sm rounded-lg bg-white p-6 shadow-xl dark:bg-neutral-900"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="login-title"
|
aria-labelledby="login-title"
|
||||||
|
|
@ -97,13 +181,16 @@
|
||||||
type="button"
|
type="button"
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
class="absolute top-3 right-3 text-2xl leading-none text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300"
|
class="absolute top-3 right-3 text-2xl leading-none text-neutral-400 hover:text-neutral-700 dark:text-neutral-500 dark:hover:text-neutral-300"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<h2 id="login-title" class="mb-4 text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
<h2
|
||||||
{view === "extension" ? "Log in" : "Log in with nsec"}
|
id="login-title"
|
||||||
|
class="mb-4 text-lg font-semibold text-neutral-900 dark:text-neutral-100"
|
||||||
|
>
|
||||||
|
{titles[view]}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
|
|
@ -128,6 +215,14 @@
|
||||||
No Nostr extension detected in this browser.
|
No Nostr extension detected in this browser.
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={showBunkerView}
|
||||||
|
disabled={busy}
|
||||||
|
class="bg-accent hover:bg-accent-hover mt-3 w-full rounded px-3 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Log in with a bunker
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={showNsecView}
|
onclick={showNsecView}
|
||||||
|
|
@ -135,6 +230,80 @@
|
||||||
>
|
>
|
||||||
Or log in using your nsec
|
Or log in using your nsec
|
||||||
</button>
|
</button>
|
||||||
|
{:else if view === "bunker"}
|
||||||
|
{#if connect}
|
||||||
|
<a
|
||||||
|
href={connect.uri}
|
||||||
|
aria-label="Open in your signer app"
|
||||||
|
class="mx-auto block w-48 rounded bg-white p-1 [&>svg]:h-auto [&>svg]:w-full"
|
||||||
|
>
|
||||||
|
{@html qrSvg}
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={copyUri}
|
||||||
|
aria-label={copied ? "Copied" : "Copy the connection string"}
|
||||||
|
class="mx-auto mt-3 flex w-full max-w-64 items-center gap-2 rounded-full bg-neutral-100 px-3 py-1.5 text-xs text-neutral-600 hover:bg-neutral-200 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
|
||||||
|
>
|
||||||
|
<span class="min-w-0 flex-1 truncate font-mono">{connect.uri}</span>
|
||||||
|
<span class="shrink-0">{copied ? "Copied" : "Copy"}</span>
|
||||||
|
</button>
|
||||||
|
<p
|
||||||
|
class="mt-2 text-center text-xs text-neutral-500 dark:text-neutral-400"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
Scan or paste this in your signer app, then approve the connection.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="my-4 flex items-center gap-3" aria-hidden="true">
|
||||||
|
<div
|
||||||
|
class="flex-1 border-t border-neutral-200 dark:border-neutral-700"
|
||||||
|
></div>
|
||||||
|
<span class="text-xs text-neutral-400">or</span>
|
||||||
|
<div
|
||||||
|
class="flex-1 border-t border-neutral-200 dark:border-neutral-700"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label
|
||||||
|
for="bunker-input"
|
||||||
|
class="mb-1 block text-sm text-neutral-700 dark:text-neutral-300"
|
||||||
|
>
|
||||||
|
Paste a bunker URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="bunker-input"
|
||||||
|
bind:this={bunkerInput}
|
||||||
|
type="text"
|
||||||
|
placeholder="bunker://…"
|
||||||
|
bind:value={bunkerUrl}
|
||||||
|
disabled={busy}
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
autocorrect="off"
|
||||||
|
spellcheck="false"
|
||||||
|
onkeydown={(e) => e.key === "Enter" && handleBunker()}
|
||||||
|
class="focus:ring-accent w-full rounded border border-neutral-200 px-3 py-2 font-mono text-sm focus:ring-1 focus:outline-none disabled:opacity-50 dark:border-neutral-700"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onclick={handleBunker}
|
||||||
|
disabled={busy || !bunkerUrl.trim()}
|
||||||
|
class="bg-accent hover:bg-accent-hover mt-3 w-full rounded px-3 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy ? "Connecting…" : "Log in"}
|
||||||
|
</button>
|
||||||
|
<p class="mt-2 text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
A NIP-05 address that points to a bunker works too.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={showExtensionView}
|
||||||
|
disabled={busy}
|
||||||
|
class="text-accent mt-4 block w-full text-center text-sm hover:underline disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<label for="nsec-input" class="sr-only">nsec</label>
|
<label for="nsec-input" class="sr-only">nsec</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -149,7 +318,7 @@
|
||||||
autocorrect="off"
|
autocorrect="off"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
onkeydown={(e) => e.key === "Enter" && handleNsec()}
|
onkeydown={(e) => e.key === "Enter" && handleNsec()}
|
||||||
class="focus:ring-accent w-full rounded border border-neutral-200 dark:border-neutral-700 px-3 py-2 font-mono text-sm focus:ring-1 focus:outline-none disabled:opacity-50"
|
class="focus:ring-accent w-full rounded border border-neutral-200 px-3 py-2 font-mono text-sm focus:ring-1 focus:outline-none disabled:opacity-50 dark:border-neutral-700"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onclick={handleNsec}
|
onclick={handleNsec}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,15 @@ export const GROUP_ID = env.PUBLIC_GROUP_ID ?? "";
|
||||||
if (MODE === "simple" && !GROUP_ID) {
|
if (MODE === "simple" && !GROUP_ID) {
|
||||||
throw new Error("PUBLIC_GROUP_ID is required in simple mode");
|
throw new Error("PUBLIC_GROUP_ID is required in simple mode");
|
||||||
}
|
}
|
||||||
|
// Relays used to hand a nostrconnect:// URI to a remote signer (NIP-46).
|
||||||
|
// Comma-separated override via PUBLIC_NOSTRCONNECT_RELAYS.
|
||||||
|
export const NOSTRCONNECT_RELAYS: string[] = (
|
||||||
|
env.PUBLIC_NOSTRCONNECT_RELAYS ||
|
||||||
|
"wss://bucket.coracle.social,wss://relay.primal.net"
|
||||||
|
)
|
||||||
|
.split(",")
|
||||||
|
.map((r) => r.trim())
|
||||||
|
.filter(Boolean);
|
||||||
// Server-side rendering: opt-in, decided at build time (vite.config.ts bakes
|
// Server-side rendering: opt-in, decided at build time (vite.config.ts bakes
|
||||||
// PUBLIC_SSR in; svelte.config.js picks the matching adapter).
|
// PUBLIC_SSR in; svelte.config.js picks the matching adapter).
|
||||||
export const SSR_ENABLED = __SQUALK_SSR__;
|
export const SSR_ENABLED = __SQUALK_SSR__;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue