@formstr/signer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,298 @@
1
+ // src/ui/index.ts
2
+ import QRCode from "qrcode";
3
+ var TABS = [
4
+ { id: "create", label: "Create" },
5
+ { id: "ncryptsec", label: "Existing key" },
6
+ { id: "extension", label: "Extension" },
7
+ { id: "bunker", label: "Bunker URI" },
8
+ { id: "nostrconnect", label: "Remote (QR)" },
9
+ { id: "android", label: "Android" }
10
+ ];
11
+ function renderLoginHtml() {
12
+ const tabs = TABS.map(
13
+ (t) => `<button class="nostr-signer__tab nostr-signer__tab--${t.id}" type="button" data-tab="${t.id}">${t.label}</button>`
14
+ ).join("");
15
+ return `<div class="nostr-signer__root">
16
+ <div class="nostr-signer__modal">
17
+ <header class="nostr-signer__header">
18
+ <h2 class="nostr-signer__title">Sign in</h2>
19
+ <button class="nostr-signer__close" type="button" data-action="cancel" aria-label="Close">&times;</button>
20
+ </header>
21
+ <nav class="nostr-signer__tabs" role="tablist">${tabs}</nav>
22
+ <main class="nostr-signer__body">
23
+ <section class="nostr-signer__panel nostr-signer__panel--create" data-panel="create">
24
+ <p class="nostr-signer__hint">Generate a new nsec encrypted with your passphrase (NIP-49). Back up the ncryptsec on the next step \u2014 it's the only way back in.</p>
25
+ <form class="nostr-signer__form" data-form="create">
26
+ <label class="nostr-signer__label" for="nostr-signer-create-passphrase">Passphrase</label>
27
+ <input class="nostr-signer__input nostr-signer__input--passphrase" id="nostr-signer-create-passphrase" type="password" name="passphrase" autocomplete="new-password" required>
28
+ <button class="nostr-signer__button nostr-signer__button--primary" type="submit">Create account</button>
29
+ </form>
30
+ </section>
31
+ <section class="nostr-signer__panel nostr-signer__panel--ncryptsec" data-panel="ncryptsec" hidden>
32
+ <form class="nostr-signer__form" data-form="ncryptsec">
33
+ <label class="nostr-signer__label" for="nostr-signer-ncryptsec">ncryptsec1...</label>
34
+ <textarea class="nostr-signer__input nostr-signer__input--ncryptsec" id="nostr-signer-ncryptsec" name="ncryptsec" required></textarea>
35
+ <label class="nostr-signer__label" for="nostr-signer-ncryptsec-passphrase">Passphrase</label>
36
+ <input class="nostr-signer__input nostr-signer__input--passphrase" id="nostr-signer-ncryptsec-passphrase" type="password" name="passphrase" autocomplete="current-password" required>
37
+ <button class="nostr-signer__button nostr-signer__button--primary" type="submit">Sign in</button>
38
+ </form>
39
+ </section>
40
+ <section class="nostr-signer__panel nostr-signer__panel--extension" data-panel="extension" hidden>
41
+ <p class="nostr-signer__hint">Requires a NIP-07 browser extension (nos2x, Alby, etc.).</p>
42
+ <button class="nostr-signer__button nostr-signer__button--primary" type="button" data-action="extension-login">Sign in with extension</button>
43
+ </section>
44
+ <section class="nostr-signer__panel nostr-signer__panel--bunker" data-panel="bunker" hidden>
45
+ <form class="nostr-signer__form" data-form="bunker">
46
+ <label class="nostr-signer__label" for="nostr-signer-bunker-uri">bunker:// URI</label>
47
+ <textarea class="nostr-signer__input nostr-signer__input--bunker-uri" id="nostr-signer-bunker-uri" name="uri" placeholder="bunker://&lt;pubkey&gt;?relay=wss://..." required></textarea>
48
+ <label class="nostr-signer__label" for="nostr-signer-bunker-perms">Permissions (optional, comma-separated)</label>
49
+ <input class="nostr-signer__input nostr-signer__input--perms" id="nostr-signer-bunker-perms" name="perms" placeholder="sign_event:1, nip44_encrypt">
50
+ <button class="nostr-signer__button nostr-signer__button--primary" type="submit">Connect</button>
51
+ </form>
52
+ </section>
53
+ <section class="nostr-signer__panel nostr-signer__panel--nostrconnect" data-panel="nostrconnect" hidden>
54
+ <form class="nostr-signer__form" data-form="nostrconnect">
55
+ <label class="nostr-signer__label" for="nostr-signer-nc-relays">Relays (comma-separated)</label>
56
+ <input class="nostr-signer__input nostr-signer__input--relays" id="nostr-signer-nc-relays" name="relays" placeholder="wss://relay.example, wss://other" required>
57
+ <label class="nostr-signer__label" for="nostr-signer-nc-perms">Permissions (optional, comma-separated)</label>
58
+ <input class="nostr-signer__input nostr-signer__input--perms" id="nostr-signer-nc-perms" name="perms" placeholder="sign_event:1, nip44_encrypt">
59
+ <button class="nostr-signer__button nostr-signer__button--primary" type="submit">Generate URI</button>
60
+ </form>
61
+ <div class="nostr-signer__qr" data-region="nostrconnect-qr" hidden>
62
+ <p class="nostr-signer__hint">Scan or paste this into your remote signer:</p>
63
+ <div class="nostr-signer__qr-image" data-region="nostrconnect-qr-image" aria-hidden="true"></div>
64
+ <pre class="nostr-signer__qr-uri" data-region="nostrconnect-uri"></pre>
65
+ <p class="nostr-signer__status" data-region="nostrconnect-status">Waiting for remote signer to pair...</p>
66
+ <button class="nostr-signer__button nostr-signer__button--secondary" type="button" data-action="nostrconnect-cancel">Cancel</button>
67
+ </div>
68
+ </section>
69
+ <section class="nostr-signer__panel nostr-signer__panel--android" data-panel="android" hidden>
70
+ <p class="nostr-signer__hint">Sign in with an Android external signer app (NIP-55) such as Amber.</p>
71
+ <p class="nostr-signer__status" data-region="android-status">Loading installed signers&hellip;</p>
72
+ <ul class="nostr-signer__android-apps" data-region="android-apps" hidden></ul>
73
+ </section>
74
+ <section class="nostr-signer__panel nostr-signer__panel--created" data-panel="created" hidden>
75
+ <p class="nostr-signer__hint">Account created. Back up this encrypted secret key \u2014 without it (and your passphrase) you cannot sign in again.</p>
76
+ <pre class="nostr-signer__ncryptsec-display" data-region="created-ncryptsec"></pre>
77
+ <button class="nostr-signer__button nostr-signer__button--primary" type="button" data-action="created-ack">I've backed it up</button>
78
+ </section>
79
+ </main>
80
+ <div class="nostr-signer__error" data-region="error" hidden></div>
81
+ </div>
82
+ </div>`;
83
+ }
84
+ function attachLoginListeners(rootEl, signer, handlers = {}) {
85
+ const detachers = [];
86
+ let nostrConnectAbort = null;
87
+ const on = (el, event, handler) => {
88
+ const wrapped = handler;
89
+ el.addEventListener(event, wrapped);
90
+ detachers.push(() => el.removeEventListener(event, wrapped));
91
+ };
92
+ const q = (selector) => rootEl.querySelector(selector);
93
+ const errorEl = q('[data-region="error"]');
94
+ const showError = (err) => {
95
+ const msg = typeof err === "string" ? err : err.message;
96
+ errorEl.hidden = false;
97
+ errorEl.textContent = msg;
98
+ if (typeof err !== "string") handlers.onError?.(err);
99
+ };
100
+ const clearError = () => {
101
+ errorEl.hidden = true;
102
+ errorEl.textContent = "";
103
+ };
104
+ const androidStatus = q('[data-region="android-status"]');
105
+ const androidList = q('[data-region="android-apps"]');
106
+ let androidFetchToken = 0;
107
+ const refreshAndroidApps = async () => {
108
+ const token = ++androidFetchToken;
109
+ androidList.hidden = true;
110
+ androidList.innerHTML = "";
111
+ androidStatus.hidden = false;
112
+ androidStatus.textContent = "Loading installed signers\u2026";
113
+ try {
114
+ const apps = await signer.listAndroidSignerApps();
115
+ if (token !== androidFetchToken) return;
116
+ if (apps.length === 0) {
117
+ androidStatus.textContent = "No NIP-55 signer apps installed. Install Amber (or similar) and try again.";
118
+ return;
119
+ }
120
+ androidStatus.hidden = true;
121
+ for (const app of apps) {
122
+ const li = document.createElement("li");
123
+ li.className = "nostr-signer__android-app";
124
+ const btn = document.createElement("button");
125
+ btn.type = "button";
126
+ btn.className = "nostr-signer__button nostr-signer__button--primary";
127
+ btn.dataset.packageName = app.packageName;
128
+ btn.textContent = `${app.name} (${app.packageName})`;
129
+ on(btn, "click", async () => {
130
+ clearError();
131
+ btn.disabled = true;
132
+ try {
133
+ const account = await signer.loginWithAndroidSigner({
134
+ packageName: app.packageName
135
+ });
136
+ handlers.onLogin?.(account);
137
+ } catch (err) {
138
+ btn.disabled = false;
139
+ showError(err);
140
+ }
141
+ });
142
+ li.appendChild(btn);
143
+ androidList.appendChild(li);
144
+ }
145
+ androidList.hidden = false;
146
+ } catch (err) {
147
+ if (token !== androidFetchToken) return;
148
+ androidStatus.textContent = err.message;
149
+ }
150
+ };
151
+ const selectTab = (tab) => {
152
+ rootEl.querySelectorAll("[data-panel]").forEach((panel) => {
153
+ panel.hidden = panel.dataset.panel !== tab;
154
+ });
155
+ rootEl.querySelectorAll("[data-tab]").forEach((btn) => {
156
+ btn.classList.toggle("nostr-signer__tab--active", btn.dataset.tab === tab);
157
+ });
158
+ clearError();
159
+ if (tab === "android") void refreshAndroidApps();
160
+ };
161
+ selectTab(handlers.defaultTab ?? "create");
162
+ rootEl.querySelectorAll("[data-tab]").forEach((btn) => {
163
+ on(btn, "click", () => selectTab(btn.dataset.tab));
164
+ });
165
+ on(q('[data-action="cancel"]'), "click", () => handlers.onCancel?.());
166
+ const createForm = q('[data-form="create"]');
167
+ on(createForm, "submit", async (ev) => {
168
+ ev.preventDefault();
169
+ clearError();
170
+ const passphrase = createForm.elements.namedItem("passphrase").value;
171
+ try {
172
+ const { ncryptsec } = await signer.createAccount(passphrase);
173
+ q('[data-region="created-ncryptsec"]').textContent = ncryptsec;
174
+ rootEl.querySelectorAll("[data-panel]").forEach((p) => {
175
+ p.hidden = p.dataset.panel !== "created";
176
+ });
177
+ } catch (err) {
178
+ showError(err);
179
+ }
180
+ });
181
+ on(q('[data-action="created-ack"]'), "click", () => {
182
+ const active = signer.getActiveAccount();
183
+ if (active) handlers.onLogin?.(active);
184
+ });
185
+ const ncryptsecForm = q('[data-form="ncryptsec"]');
186
+ on(ncryptsecForm, "submit", async (ev) => {
187
+ ev.preventDefault();
188
+ clearError();
189
+ const ncryptsec = ncryptsecForm.elements.namedItem("ncryptsec").value.trim();
190
+ const passphrase = ncryptsecForm.elements.namedItem("passphrase").value;
191
+ try {
192
+ const account = await signer.loginWithNcryptsec(ncryptsec, passphrase);
193
+ handlers.onLogin?.(account);
194
+ } catch (err) {
195
+ showError(err);
196
+ }
197
+ });
198
+ on(q('[data-action="extension-login"]'), "click", async () => {
199
+ clearError();
200
+ try {
201
+ const account = await signer.loginWithExtension();
202
+ handlers.onLogin?.(account);
203
+ } catch (err) {
204
+ showError(err);
205
+ }
206
+ });
207
+ const bunkerForm = q('[data-form="bunker"]');
208
+ on(bunkerForm, "submit", async (ev) => {
209
+ ev.preventDefault();
210
+ clearError();
211
+ const uri = bunkerForm.elements.namedItem("uri").value.trim();
212
+ const perms = bunkerForm.elements.namedItem("perms").value.split(",").map((s) => s.trim()).filter(Boolean);
213
+ try {
214
+ const account = await signer.loginWithBunkerUri(uri, {
215
+ pool: handlers.pool,
216
+ onRelayMismatch: handlers.onRelayMismatch,
217
+ perms: perms.length > 0 ? perms : void 0
218
+ });
219
+ handlers.onLogin?.(account);
220
+ } catch (err) {
221
+ showError(err);
222
+ }
223
+ });
224
+ const ncForm = q('[data-form="nostrconnect"]');
225
+ const ncQrRegion = q('[data-region="nostrconnect-qr"]');
226
+ const ncQrUri = q('[data-region="nostrconnect-uri"]');
227
+ const ncQrImage = q('[data-region="nostrconnect-qr-image"]');
228
+ const ncStatus = q('[data-region="nostrconnect-status"]');
229
+ let ncQrToken = 0;
230
+ const resetNostrConnect = () => {
231
+ ncForm.hidden = false;
232
+ ncQrRegion.hidden = true;
233
+ ncQrUri.textContent = "";
234
+ ncQrImage.innerHTML = "";
235
+ ncStatus.textContent = "Waiting for remote signer to pair...";
236
+ ncQrToken++;
237
+ };
238
+ const renderNostrConnectQr = async (uri) => {
239
+ const token = ++ncQrToken;
240
+ try {
241
+ const svg = await QRCode.toString(uri, {
242
+ type: "svg",
243
+ margin: 1,
244
+ errorCorrectionLevel: "M"
245
+ });
246
+ if (token === ncQrToken) ncQrImage.innerHTML = svg;
247
+ } catch {
248
+ }
249
+ };
250
+ on(ncForm, "submit", async (ev) => {
251
+ ev.preventDefault();
252
+ clearError();
253
+ const relays = ncForm.elements.namedItem("relays").value.split(",").map((s) => s.trim()).filter(Boolean);
254
+ if (relays.length === 0) {
255
+ showError("At least one relay is required.");
256
+ return;
257
+ }
258
+ const perms = ncForm.elements.namedItem("perms").value.split(",").map((s) => s.trim()).filter(Boolean);
259
+ ncForm.hidden = true;
260
+ ncQrRegion.hidden = false;
261
+ nostrConnectAbort = new AbortController();
262
+ try {
263
+ const account = await signer.loginWithNostrConnect({
264
+ relays,
265
+ perms: perms.length > 0 ? perms : void 0,
266
+ pool: handlers.pool,
267
+ onRelayMismatch: handlers.onRelayMismatch,
268
+ signal: nostrConnectAbort.signal,
269
+ onUri: (uri) => {
270
+ ncQrUri.textContent = uri;
271
+ void renderNostrConnectQr(uri);
272
+ }
273
+ });
274
+ handlers.onLogin?.(account);
275
+ } catch (err) {
276
+ resetNostrConnect();
277
+ if (err.name !== "AbortError") showError(err);
278
+ } finally {
279
+ nostrConnectAbort = null;
280
+ }
281
+ });
282
+ on(q('[data-action="nostrconnect-cancel"]'), "click", () => {
283
+ nostrConnectAbort?.abort();
284
+ resetNostrConnect();
285
+ });
286
+ return {
287
+ detach: () => {
288
+ nostrConnectAbort?.abort();
289
+ for (const d of detachers) d();
290
+ },
291
+ selectTab
292
+ };
293
+ }
294
+ export {
295
+ attachLoginListeners,
296
+ renderLoginHtml
297
+ };
298
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/ui/index.ts"],"sourcesContent":["import QRCode from 'qrcode';\nimport type { AbstractSimplePool } from 'nostr-tools/abstract-pool';\nimport type { Signer } from '../core/signer.js';\nimport type { RelayMismatchHandler, StoredAccount } from '../core/types.js';\n\nexport type LoginTab = 'create' | 'ncryptsec' | 'extension' | 'bunker' | 'nostrconnect' | 'android';\n\nexport interface LoginUiHandlers {\n onLogin?: (account: StoredAccount) => void;\n onCancel?: () => void;\n onError?: (error: Error) => void;\n /** Optional: passed through to NIP-46 login methods. */\n pool?: AbstractSimplePool;\n /** Optional: passed through to NIP-46 login methods. */\n onRelayMismatch?: RelayMismatchHandler;\n /** Initial tab to show. Defaults to 'create'. */\n defaultTab?: LoginTab;\n}\n\nexport interface LoginUiBinding {\n detach(): void;\n selectTab(tab: LoginTab): void;\n}\n\nconst TABS: ReadonlyArray<{ id: LoginTab; label: string }> = [\n { id: 'create', label: 'Create' },\n { id: 'ncryptsec', label: 'Existing key' },\n { id: 'extension', label: 'Extension' },\n { id: 'bunker', label: 'Bunker URI' },\n { id: 'nostrconnect', label: 'Remote (QR)' },\n { id: 'android', label: 'Android' },\n];\n\nexport function renderLoginHtml(): string {\n const tabs = TABS.map(\n (t) =>\n `<button class=\"nostr-signer__tab nostr-signer__tab--${t.id}\" type=\"button\" data-tab=\"${t.id}\">${t.label}</button>`,\n ).join('');\n\n return `<div class=\"nostr-signer__root\">\n <div class=\"nostr-signer__modal\">\n <header class=\"nostr-signer__header\">\n <h2 class=\"nostr-signer__title\">Sign in</h2>\n <button class=\"nostr-signer__close\" type=\"button\" data-action=\"cancel\" aria-label=\"Close\">&times;</button>\n </header>\n <nav class=\"nostr-signer__tabs\" role=\"tablist\">${tabs}</nav>\n <main class=\"nostr-signer__body\">\n <section class=\"nostr-signer__panel nostr-signer__panel--create\" data-panel=\"create\">\n <p class=\"nostr-signer__hint\">Generate a new nsec encrypted with your passphrase (NIP-49). Back up the ncryptsec on the next step — it's the only way back in.</p>\n <form class=\"nostr-signer__form\" data-form=\"create\">\n <label class=\"nostr-signer__label\" for=\"nostr-signer-create-passphrase\">Passphrase</label>\n <input class=\"nostr-signer__input nostr-signer__input--passphrase\" id=\"nostr-signer-create-passphrase\" type=\"password\" name=\"passphrase\" autocomplete=\"new-password\" required>\n <button class=\"nostr-signer__button nostr-signer__button--primary\" type=\"submit\">Create account</button>\n </form>\n </section>\n <section class=\"nostr-signer__panel nostr-signer__panel--ncryptsec\" data-panel=\"ncryptsec\" hidden>\n <form class=\"nostr-signer__form\" data-form=\"ncryptsec\">\n <label class=\"nostr-signer__label\" for=\"nostr-signer-ncryptsec\">ncryptsec1...</label>\n <textarea class=\"nostr-signer__input nostr-signer__input--ncryptsec\" id=\"nostr-signer-ncryptsec\" name=\"ncryptsec\" required></textarea>\n <label class=\"nostr-signer__label\" for=\"nostr-signer-ncryptsec-passphrase\">Passphrase</label>\n <input class=\"nostr-signer__input nostr-signer__input--passphrase\" id=\"nostr-signer-ncryptsec-passphrase\" type=\"password\" name=\"passphrase\" autocomplete=\"current-password\" required>\n <button class=\"nostr-signer__button nostr-signer__button--primary\" type=\"submit\">Sign in</button>\n </form>\n </section>\n <section class=\"nostr-signer__panel nostr-signer__panel--extension\" data-panel=\"extension\" hidden>\n <p class=\"nostr-signer__hint\">Requires a NIP-07 browser extension (nos2x, Alby, etc.).</p>\n <button class=\"nostr-signer__button nostr-signer__button--primary\" type=\"button\" data-action=\"extension-login\">Sign in with extension</button>\n </section>\n <section class=\"nostr-signer__panel nostr-signer__panel--bunker\" data-panel=\"bunker\" hidden>\n <form class=\"nostr-signer__form\" data-form=\"bunker\">\n <label class=\"nostr-signer__label\" for=\"nostr-signer-bunker-uri\">bunker:// URI</label>\n <textarea class=\"nostr-signer__input nostr-signer__input--bunker-uri\" id=\"nostr-signer-bunker-uri\" name=\"uri\" placeholder=\"bunker://&lt;pubkey&gt;?relay=wss://...\" required></textarea>\n <label class=\"nostr-signer__label\" for=\"nostr-signer-bunker-perms\">Permissions (optional, comma-separated)</label>\n <input class=\"nostr-signer__input nostr-signer__input--perms\" id=\"nostr-signer-bunker-perms\" name=\"perms\" placeholder=\"sign_event:1, nip44_encrypt\">\n <button class=\"nostr-signer__button nostr-signer__button--primary\" type=\"submit\">Connect</button>\n </form>\n </section>\n <section class=\"nostr-signer__panel nostr-signer__panel--nostrconnect\" data-panel=\"nostrconnect\" hidden>\n <form class=\"nostr-signer__form\" data-form=\"nostrconnect\">\n <label class=\"nostr-signer__label\" for=\"nostr-signer-nc-relays\">Relays (comma-separated)</label>\n <input class=\"nostr-signer__input nostr-signer__input--relays\" id=\"nostr-signer-nc-relays\" name=\"relays\" placeholder=\"wss://relay.example, wss://other\" required>\n <label class=\"nostr-signer__label\" for=\"nostr-signer-nc-perms\">Permissions (optional, comma-separated)</label>\n <input class=\"nostr-signer__input nostr-signer__input--perms\" id=\"nostr-signer-nc-perms\" name=\"perms\" placeholder=\"sign_event:1, nip44_encrypt\">\n <button class=\"nostr-signer__button nostr-signer__button--primary\" type=\"submit\">Generate URI</button>\n </form>\n <div class=\"nostr-signer__qr\" data-region=\"nostrconnect-qr\" hidden>\n <p class=\"nostr-signer__hint\">Scan or paste this into your remote signer:</p>\n <div class=\"nostr-signer__qr-image\" data-region=\"nostrconnect-qr-image\" aria-hidden=\"true\"></div>\n <pre class=\"nostr-signer__qr-uri\" data-region=\"nostrconnect-uri\"></pre>\n <p class=\"nostr-signer__status\" data-region=\"nostrconnect-status\">Waiting for remote signer to pair...</p>\n <button class=\"nostr-signer__button nostr-signer__button--secondary\" type=\"button\" data-action=\"nostrconnect-cancel\">Cancel</button>\n </div>\n </section>\n <section class=\"nostr-signer__panel nostr-signer__panel--android\" data-panel=\"android\" hidden>\n <p class=\"nostr-signer__hint\">Sign in with an Android external signer app (NIP-55) such as Amber.</p>\n <p class=\"nostr-signer__status\" data-region=\"android-status\">Loading installed signers&hellip;</p>\n <ul class=\"nostr-signer__android-apps\" data-region=\"android-apps\" hidden></ul>\n </section>\n <section class=\"nostr-signer__panel nostr-signer__panel--created\" data-panel=\"created\" hidden>\n <p class=\"nostr-signer__hint\">Account created. Back up this encrypted secret key — without it (and your passphrase) you cannot sign in again.</p>\n <pre class=\"nostr-signer__ncryptsec-display\" data-region=\"created-ncryptsec\"></pre>\n <button class=\"nostr-signer__button nostr-signer__button--primary\" type=\"button\" data-action=\"created-ack\">I've backed it up</button>\n </section>\n </main>\n <div class=\"nostr-signer__error\" data-region=\"error\" hidden></div>\n </div>\n</div>`;\n}\n\nexport function attachLoginListeners(\n rootEl: HTMLElement,\n signer: Signer,\n handlers: LoginUiHandlers = {},\n): LoginUiBinding {\n const detachers: Array<() => void> = [];\n let nostrConnectAbort: AbortController | null = null;\n\n const on = <K extends keyof HTMLElementEventMap>(\n el: Element,\n event: K,\n handler: (ev: HTMLElementEventMap[K]) => void,\n ): void => {\n const wrapped = handler as EventListener;\n el.addEventListener(event, wrapped);\n detachers.push(() => el.removeEventListener(event, wrapped));\n };\n\n const q = <T extends Element = Element>(selector: string): T =>\n rootEl.querySelector(selector) as T;\n\n const errorEl = q<HTMLDivElement>('[data-region=\"error\"]');\n const showError = (err: Error | string): void => {\n const msg = typeof err === 'string' ? err : err.message;\n errorEl.hidden = false;\n errorEl.textContent = msg;\n if (typeof err !== 'string') handlers.onError?.(err);\n };\n const clearError = (): void => {\n errorEl.hidden = true;\n errorEl.textContent = '';\n };\n\n // ---- android (NIP-55) — declared before selectTab so it can be invoked\n // when the user opens with defaultTab: 'android'.\n const androidStatus = q<HTMLElement>('[data-region=\"android-status\"]');\n const androidList = q<HTMLUListElement>('[data-region=\"android-apps\"]');\n let androidFetchToken = 0;\n\n const refreshAndroidApps = async (): Promise<void> => {\n const token = ++androidFetchToken;\n androidList.hidden = true;\n androidList.innerHTML = '';\n androidStatus.hidden = false;\n androidStatus.textContent = 'Loading installed signers…';\n try {\n const apps = await signer.listAndroidSignerApps();\n if (token !== androidFetchToken) return;\n if (apps.length === 0) {\n androidStatus.textContent =\n 'No NIP-55 signer apps installed. Install Amber (or similar) and try again.';\n return;\n }\n androidStatus.hidden = true;\n for (const app of apps) {\n const li = document.createElement('li');\n li.className = 'nostr-signer__android-app';\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = 'nostr-signer__button nostr-signer__button--primary';\n btn.dataset.packageName = app.packageName;\n btn.textContent = `${app.name} (${app.packageName})`;\n on(btn, 'click', async () => {\n clearError();\n btn.disabled = true;\n try {\n const account = await signer.loginWithAndroidSigner({\n packageName: app.packageName,\n });\n handlers.onLogin?.(account);\n } catch (err) {\n btn.disabled = false;\n showError(err as Error);\n }\n });\n li.appendChild(btn);\n androidList.appendChild(li);\n }\n androidList.hidden = false;\n } catch (err) {\n if (token !== androidFetchToken) return;\n androidStatus.textContent = (err as Error).message;\n }\n };\n\n const selectTab = (tab: LoginTab): void => {\n rootEl.querySelectorAll<HTMLElement>('[data-panel]').forEach((panel) => {\n panel.hidden = panel.dataset.panel !== tab;\n });\n rootEl.querySelectorAll<HTMLElement>('[data-tab]').forEach((btn) => {\n btn.classList.toggle('nostr-signer__tab--active', btn.dataset.tab === tab);\n });\n clearError();\n if (tab === 'android') void refreshAndroidApps();\n };\n\n selectTab(handlers.defaultTab ?? 'create');\n\n rootEl.querySelectorAll<HTMLButtonElement>('[data-tab]').forEach((btn) => {\n on(btn, 'click', () => selectTab(btn.dataset.tab as LoginTab));\n });\n\n on(q('[data-action=\"cancel\"]'), 'click', () => handlers.onCancel?.());\n\n // ---- create ----\n const createForm = q<HTMLFormElement>('[data-form=\"create\"]');\n on(createForm, 'submit', async (ev) => {\n ev.preventDefault();\n clearError();\n const passphrase = (createForm.elements.namedItem('passphrase') as HTMLInputElement).value;\n try {\n const { ncryptsec } = await signer.createAccount(passphrase);\n q<HTMLElement>('[data-region=\"created-ncryptsec\"]').textContent = ncryptsec;\n rootEl.querySelectorAll<HTMLElement>('[data-panel]').forEach((p) => {\n p.hidden = p.dataset.panel !== 'created';\n });\n } catch (err) {\n showError(err as Error);\n }\n });\n\n on(q('[data-action=\"created-ack\"]'), 'click', () => {\n const active = signer.getActiveAccount();\n if (active) handlers.onLogin?.(active);\n });\n\n // ---- ncryptsec ----\n const ncryptsecForm = q<HTMLFormElement>('[data-form=\"ncryptsec\"]');\n on(ncryptsecForm, 'submit', async (ev) => {\n ev.preventDefault();\n clearError();\n const ncryptsec = (\n ncryptsecForm.elements.namedItem('ncryptsec') as HTMLTextAreaElement\n ).value.trim();\n const passphrase = (\n ncryptsecForm.elements.namedItem('passphrase') as HTMLInputElement\n ).value;\n try {\n const account = await signer.loginWithNcryptsec(ncryptsec, passphrase);\n handlers.onLogin?.(account);\n } catch (err) {\n showError(err as Error);\n }\n });\n\n // ---- extension ----\n on(q('[data-action=\"extension-login\"]'), 'click', async () => {\n clearError();\n try {\n const account = await signer.loginWithExtension();\n handlers.onLogin?.(account);\n } catch (err) {\n showError(err as Error);\n }\n });\n\n // ---- bunker URI ----\n const bunkerForm = q<HTMLFormElement>('[data-form=\"bunker\"]');\n on(bunkerForm, 'submit', async (ev) => {\n ev.preventDefault();\n clearError();\n const uri = (\n bunkerForm.elements.namedItem('uri') as HTMLTextAreaElement\n ).value.trim();\n const perms = (bunkerForm.elements.namedItem('perms') as HTMLInputElement).value\n .split(',')\n .map((s) => s.trim())\n .filter(Boolean);\n try {\n const account = await signer.loginWithBunkerUri(uri, {\n pool: handlers.pool,\n onRelayMismatch: handlers.onRelayMismatch,\n perms: perms.length > 0 ? perms : undefined,\n });\n handlers.onLogin?.(account);\n } catch (err) {\n showError(err as Error);\n }\n });\n\n // ---- nostrconnect ----\n const ncForm = q<HTMLFormElement>('[data-form=\"nostrconnect\"]');\n const ncQrRegion = q<HTMLDivElement>('[data-region=\"nostrconnect-qr\"]');\n const ncQrUri = q<HTMLElement>('[data-region=\"nostrconnect-uri\"]');\n const ncQrImage = q<HTMLDivElement>('[data-region=\"nostrconnect-qr-image\"]');\n const ncStatus = q<HTMLElement>('[data-region=\"nostrconnect-status\"]');\n let ncQrToken = 0;\n\n const resetNostrConnect = (): void => {\n ncForm.hidden = false;\n ncQrRegion.hidden = true;\n ncQrUri.textContent = '';\n ncQrImage.innerHTML = '';\n ncStatus.textContent = 'Waiting for remote signer to pair...';\n ncQrToken++;\n };\n\n const renderNostrConnectQr = async (uri: string): Promise<void> => {\n const token = ++ncQrToken;\n try {\n const svg = await QRCode.toString(uri, {\n type: 'svg',\n margin: 1,\n errorCorrectionLevel: 'M',\n });\n if (token === ncQrToken) ncQrImage.innerHTML = svg;\n } catch {\n // QR rendering is a nice-to-have — the URI text remains usable.\n }\n };\n\n on(ncForm, 'submit', async (ev) => {\n ev.preventDefault();\n clearError();\n const relays = (ncForm.elements.namedItem('relays') as HTMLInputElement).value\n .split(',')\n .map((s) => s.trim())\n .filter(Boolean);\n if (relays.length === 0) {\n showError('At least one relay is required.');\n return;\n }\n const perms = (ncForm.elements.namedItem('perms') as HTMLInputElement).value\n .split(',')\n .map((s) => s.trim())\n .filter(Boolean);\n\n ncForm.hidden = true;\n ncQrRegion.hidden = false;\n nostrConnectAbort = new AbortController();\n try {\n const account = await signer.loginWithNostrConnect({\n relays,\n perms: perms.length > 0 ? perms : undefined,\n pool: handlers.pool,\n onRelayMismatch: handlers.onRelayMismatch,\n signal: nostrConnectAbort.signal,\n onUri: (uri) => {\n ncQrUri.textContent = uri;\n void renderNostrConnectQr(uri);\n },\n });\n handlers.onLogin?.(account);\n } catch (err) {\n resetNostrConnect();\n if ((err as Error).name !== 'AbortError') showError(err as Error);\n } finally {\n nostrConnectAbort = null;\n }\n });\n\n on(q('[data-action=\"nostrconnect-cancel\"]'), 'click', () => {\n nostrConnectAbort?.abort();\n resetNostrConnect();\n });\n\n return {\n detach: () => {\n nostrConnectAbort?.abort();\n for (const d of detachers) d();\n },\n selectTab,\n };\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,85 @@
1
+ {
2
+ "name": "@formstr/signer",
3
+ "version": "0.1.0",
4
+ "description": "Nostr signer with login UI for NIP-07, NIP-46, NIP-49 (ncryptsec), and NIP-55",
5
+ "license": "MIT",
6
+ "author": "Abhay <toabhayraizada@gmail.com>",
7
+ "homepage": "https://github.com/formstr-hq/common-packages/tree/main/packages/signer#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/formstr-hq/common-packages.git",
11
+ "directory": "packages/signer"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/formstr-hq/common-packages/issues"
15
+ },
16
+ "keywords": [
17
+ "nostr",
18
+ "signer",
19
+ "nip-07",
20
+ "nip-46",
21
+ "nip-49",
22
+ "nip-55",
23
+ "ncryptsec",
24
+ "bunker",
25
+ "nostr-connect",
26
+ "amber",
27
+ "formstr"
28
+ ],
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "type": "module",
33
+ "main": "./dist/index.cjs",
34
+ "module": "./dist/index.js",
35
+ "types": "./dist/index.d.ts",
36
+ "exports": {
37
+ ".": {
38
+ "types": "./dist/index.d.ts",
39
+ "import": "./dist/index.js",
40
+ "require": "./dist/index.cjs"
41
+ },
42
+ "./ui": {
43
+ "types": "./dist/ui/index.d.ts",
44
+ "import": "./dist/ui/index.js",
45
+ "require": "./dist/ui/index.cjs"
46
+ },
47
+ "./styles.css": "./styles/signer.css"
48
+ },
49
+ "files": [
50
+ "dist",
51
+ "styles",
52
+ "LICENSE",
53
+ "README.md"
54
+ ],
55
+ "scripts": {
56
+ "build": "tsup",
57
+ "dev": "tsup --watch",
58
+ "typecheck": "tsc --noEmit",
59
+ "test": "vitest run",
60
+ "test:watch": "vitest",
61
+ "test:coverage": "vitest run --coverage",
62
+ "clean": "rm -rf dist"
63
+ },
64
+ "dependencies": {
65
+ "nostr-tools": "^2.7.0",
66
+ "qrcode": "^1.5.4"
67
+ },
68
+ "peerDependencies": {
69
+ "nostr-signer-capacitor-plugin": "^0.0.5"
70
+ },
71
+ "peerDependenciesMeta": {
72
+ "nostr-signer-capacitor-plugin": {
73
+ "optional": true
74
+ }
75
+ },
76
+ "devDependencies": {
77
+ "@types/qrcode": "^1.5.5",
78
+ "@vitest/coverage-v8": "^2.0.0",
79
+ "happy-dom": "^15.0.0",
80
+ "nostr-signer-capacitor-plugin": "^0.0.5",
81
+ "tsup": "^8.0.0",
82
+ "typescript": "^5.5.0",
83
+ "vitest": "^2.0.0"
84
+ }
85
+ }