LoginModal.svelte raw
1 <script>
2 import { createEventDispatcher, onMount } from 'svelte';
3 import { generateSecretKey, getPublicKey } from 'nostr-tools/pure';
4 import { nsecEncode, npubEncode, decode } from 'nostr-tools/nip19';
5 import { finalizeEvent } from 'nostr-tools/pure';
6
7 const dispatch = createEventDispatcher();
8
9 export let showModal = false;
10 export let isDarkTheme = false;
11
12 let activeTab = 'extension';
13 let nsecInput = '';
14 let isLoading = false;
15 let errorMessage = '';
16 let successMessage = '';
17 let generatedNsec = '';
18 let generatedNpub = '';
19
20 function closeModal() {
21 showModal = false;
22 nsecInput = '';
23 errorMessage = '';
24 successMessage = '';
25 generatedNsec = '';
26 generatedNpub = '';
27 dispatch('close');
28 }
29
30 function switchTab(tab) {
31 activeTab = tab;
32 errorMessage = '';
33 successMessage = '';
34 generatedNsec = '';
35 generatedNpub = '';
36 }
37
38 async function generateNewKey() {
39 errorMessage = '';
40 successMessage = '';
41
42 try {
43 const secretKey = generateSecretKey();
44 const nsec = nsecEncode(secretKey);
45 const pubkey = getPublicKey(secretKey);
46 const npub = npubEncode(pubkey);
47
48 generatedNsec = nsec;
49 generatedNpub = npub;
50 nsecInput = nsec;
51
52 successMessage = 'New key generated!';
53 } catch (error) {
54 errorMessage = 'Failed to generate key: ' + error.message;
55 }
56 }
57
58 async function loginWithExtension() {
59 isLoading = true;
60 errorMessage = '';
61 successMessage = '';
62
63 try {
64 if (!window.nostr) {
65 throw new Error('No Nostr extension found. Please install nos2x or Alby.');
66 }
67
68 const pubkey = await window.nostr.getPublicKey();
69
70 if (pubkey) {
71 successMessage = 'Successfully logged in with extension!';
72 dispatch('login', {
73 method: 'extension',
74 pubkey: pubkey,
75 signer: window.nostr,
76 });
77
78 setTimeout(closeModal, 500);
79 }
80 } catch (error) {
81 errorMessage = error.message;
82 } finally {
83 isLoading = false;
84 }
85 }
86
87 async function loginWithNsec() {
88 isLoading = true;
89 errorMessage = '';
90 successMessage = '';
91
92 try {
93 if (!nsecInput.trim()) {
94 throw new Error('Please enter your nsec');
95 }
96
97 const trimmed = nsecInput.trim();
98
99 // Decode nsec
100 let decoded;
101 try {
102 decoded = decode(trimmed);
103 } catch {
104 throw new Error('Invalid nsec format');
105 }
106
107 if (decoded.type !== 'nsec') {
108 throw new Error('Please enter an nsec (private key)');
109 }
110
111 const secretKey = decoded.data;
112 const publicKey = getPublicKey(secretKey);
113
114 // Create a signer that uses the secret key
115 const signer = {
116 getPublicKey: async () => publicKey,
117 signEvent: async (event) => {
118 return finalizeEvent(event, secretKey);
119 }
120 };
121
122 successMessage = 'Successfully logged in!';
123 dispatch('login', {
124 method: 'nsec',
125 pubkey: publicKey,
126 privateKey: trimmed,
127 signer: signer,
128 });
129
130 setTimeout(closeModal, 500);
131 } catch (error) {
132 errorMessage = error.message;
133 } finally {
134 isLoading = false;
135 }
136 }
137
138 function handleKeydown(event) {
139 if (event.key === 'Escape') {
140 closeModal();
141 }
142 if (event.key === 'Enter' && activeTab === 'nsec') {
143 loginWithNsec();
144 }
145 }
146 </script>
147
148 <svelte:window on:keydown={handleKeydown} />
149
150 {#if showModal}
151 <div
152 class="modal-overlay"
153 on:click={closeModal}
154 on:keydown={(e) => e.key === 'Escape' && closeModal()}
155 role="button"
156 tabindex="0"
157 >
158 <div
159 class="modal"
160 class:dark-theme={isDarkTheme}
161 on:click|stopPropagation
162 on:keydown|stopPropagation
163 >
164 <div class="modal-header">
165 <h2>Login to Launcher Admin</h2>
166 <button class="close-btn" on:click={closeModal}>×</button>
167 </div>
168
169 <div class="tab-container">
170 <div class="tabs">
171 <button
172 class="tab-btn"
173 class:active={activeTab === 'extension'}
174 on:click={() => switchTab('extension')}
175 >
176 Extension
177 </button>
178 <button
179 class="tab-btn"
180 class:active={activeTab === 'nsec'}
181 on:click={() => switchTab('nsec')}
182 >
183 Nsec
184 </button>
185 </div>
186
187 <div class="tab-content">
188 {#if activeTab === 'extension'}
189 <div class="extension-login">
190 <p>Login using a NIP-07 browser extension like nos2x or Alby.</p>
191 <button
192 class="login-btn"
193 on:click={loginWithExtension}
194 disabled={isLoading}
195 >
196 {isLoading ? 'Connecting...' : 'Login with Extension'}
197 </button>
198 </div>
199 {:else}
200 <div class="nsec-login">
201 <p>Enter your nsec or generate a new key pair.</p>
202
203 <button
204 class="generate-btn"
205 on:click={generateNewKey}
206 disabled={isLoading}
207 >
208 Generate New Key
209 </button>
210
211 {#if generatedNpub}
212 <div class="generated-info">
213 <label>Your new public key (npub):</label>
214 <code>{generatedNpub}</code>
215 </div>
216 {/if}
217
218 <input
219 type="password"
220 placeholder="nsec1..."
221 bind:value={nsecInput}
222 disabled={isLoading}
223 class="nsec-input"
224 />
225
226 <button
227 class="login-btn"
228 on:click={loginWithNsec}
229 disabled={isLoading || !nsecInput.trim()}
230 >
231 {isLoading ? 'Logging in...' : 'Login with Nsec'}
232 </button>
233 </div>
234 {/if}
235
236 {#if errorMessage}
237 <div class="message error-message">{errorMessage}</div>
238 {/if}
239
240 {#if successMessage}
241 <div class="message success-message">{successMessage}</div>
242 {/if}
243 </div>
244 </div>
245 </div>
246 </div>
247 {/if}
248
249 <style>
250 .modal-overlay {
251 position: fixed;
252 top: 0;
253 left: 0;
254 width: 100%;
255 height: 100%;
256 background-color: rgba(0, 0, 0, 0.5);
257 display: flex;
258 justify-content: center;
259 align-items: center;
260 z-index: 1000;
261 }
262
263 .modal {
264 background: var(--card-bg, #fff);
265 border-radius: 8px;
266 box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
267 width: 90%;
268 max-width: 450px;
269 border: 1px solid var(--border-color, #e0e0e0);
270 }
271
272 .modal-header {
273 display: flex;
274 justify-content: space-between;
275 align-items: center;
276 padding: 20px;
277 border-bottom: 1px solid var(--border-color, #e0e0e0);
278 }
279
280 .modal-header h2 {
281 margin: 0;
282 color: var(--text-color, #333);
283 font-size: 1.25rem;
284 }
285
286 .close-btn {
287 background: none;
288 border: none;
289 font-size: 1.5rem;
290 cursor: pointer;
291 color: var(--text-color, #333);
292 padding: 0;
293 width: 30px;
294 height: 30px;
295 display: flex;
296 align-items: center;
297 justify-content: center;
298 border-radius: 50%;
299 }
300
301 .close-btn:hover {
302 background-color: var(--border-color, #e0e0e0);
303 }
304
305 .tab-container {
306 padding: 20px;
307 }
308
309 .tabs {
310 display: flex;
311 border-bottom: 1px solid var(--border-color, #e0e0e0);
312 margin-bottom: 20px;
313 }
314
315 .tab-btn {
316 flex: 1;
317 padding: 12px 16px;
318 background: none;
319 border: none;
320 cursor: pointer;
321 color: var(--text-color, #333);
322 font-size: 1rem;
323 border-bottom: 2px solid transparent;
324 }
325
326 .tab-btn:hover {
327 background-color: var(--border-color, #e0e0e0);
328 }
329
330 .tab-btn.active {
331 border-bottom-color: var(--primary, #00bcd4);
332 color: var(--primary, #00bcd4);
333 }
334
335 .tab-content {
336 min-height: 180px;
337 }
338
339 .extension-login,
340 .nsec-login {
341 display: flex;
342 flex-direction: column;
343 gap: 16px;
344 }
345
346 .extension-login p,
347 .nsec-login p {
348 margin: 0;
349 color: var(--muted-color, #666);
350 line-height: 1.5;
351 }
352
353 .login-btn {
354 padding: 12px 24px;
355 background: var(--primary, #00bcd4);
356 color: white;
357 border: none;
358 border-radius: 6px;
359 cursor: pointer;
360 font-size: 1rem;
361 }
362
363 .login-btn:hover:not(:disabled) {
364 background: var(--primary-hover, #00acc1);
365 }
366
367 .login-btn:disabled {
368 background: #ccc;
369 cursor: not-allowed;
370 }
371
372 .nsec-input {
373 padding: 12px;
374 border: 1px solid var(--border-color, #e0e0e0);
375 border-radius: 6px;
376 font-size: 1rem;
377 background: var(--card-bg, #fff);
378 color: var(--text-color, #333);
379 }
380
381 .nsec-input:focus {
382 outline: none;
383 border-color: var(--primary, #00bcd4);
384 }
385
386 .generate-btn {
387 padding: 10px 20px;
388 background: var(--success, #4caf50);
389 color: white;
390 border: none;
391 border-radius: 6px;
392 cursor: pointer;
393 font-size: 0.95rem;
394 }
395
396 .generate-btn:hover:not(:disabled) {
397 opacity: 0.9;
398 }
399
400 .generate-btn:disabled {
401 background: #ccc;
402 cursor: not-allowed;
403 }
404
405 .generated-info {
406 background: var(--bg-color, #f5f5f5);
407 padding: 12px;
408 border-radius: 6px;
409 border: 1px solid var(--border-color, #e0e0e0);
410 }
411
412 .generated-info label {
413 display: block;
414 font-size: 0.85rem;
415 color: var(--muted-color, #666);
416 margin-bottom: 6px;
417 }
418
419 .generated-info code {
420 display: block;
421 word-break: break-all;
422 font-size: 0.8rem;
423 color: var(--text-color, #333);
424 }
425
426 .message {
427 padding: 10px;
428 border-radius: 4px;
429 margin-top: 16px;
430 text-align: center;
431 }
432
433 .error-message {
434 background: #ffebee;
435 color: #c62828;
436 border: 1px solid #ffcdd2;
437 }
438
439 .success-message {
440 background: #e8f5e9;
441 color: #2e7d32;
442 border: 1px solid #c8e6c9;
443 }
444
445 .dark-theme .error-message {
446 background: #4a2c2a;
447 color: #ffcdd2;
448 }
449
450 .dark-theme .success-message {
451 background: #2e4a2e;
452 color: #a5d6a7;
453 }
454 </style>
455