this repo has no description
1<script lang="ts">
2 import { getAuthState } from '../lib/auth.svelte'
3 import { navigate } from '../lib/router.svelte'
4 import { api, ApiError } from '../lib/api'
5
6 const auth = getAuthState()
7
8 let loading = $state(true)
9 let saving = $state(false)
10 let error = $state<string | null>(null)
11 let success = $state<string | null>(null)
12
13 let preferredChannel = $state('email')
14 let email = $state('')
15 let discordId = $state('')
16 let discordVerified = $state(false)
17 let telegramUsername = $state('')
18 let telegramVerified = $state(false)
19 let signalNumber = $state('')
20 let signalVerified = $state(false)
21
22 $effect(() => {
23 if (!auth.loading && !auth.session) {
24 navigate('/login')
25 }
26 })
27
28 $effect(() => {
29 if (auth.session) {
30 loadPrefs()
31 }
32 })
33
34 async function loadPrefs() {
35 if (!auth.session) return
36 loading = true
37 error = null
38
39 try {
40 const prefs = await api.getNotificationPrefs(auth.session.accessJwt)
41 preferredChannel = prefs.preferredChannel
42 email = prefs.email
43 discordId = prefs.discordId ?? ''
44 discordVerified = prefs.discordVerified
45 telegramUsername = prefs.telegramUsername ?? ''
46 telegramVerified = prefs.telegramVerified
47 signalNumber = prefs.signalNumber ?? ''
48 signalVerified = prefs.signalVerified
49 } catch (e) {
50 error = e instanceof ApiError ? e.message : 'Failed to load notification preferences'
51 } finally {
52 loading = false
53 }
54 }
55
56 async function handleSave(e: Event) {
57 e.preventDefault()
58 if (!auth.session) return
59
60 saving = true
61 error = null
62 success = null
63
64 try {
65 await api.updateNotificationPrefs(auth.session.accessJwt, {
66 preferredChannel,
67 discordId: discordId || undefined,
68 telegramUsername: telegramUsername || undefined,
69 signalNumber: signalNumber || undefined,
70 })
71 success = 'Notification preferences saved'
72 await loadPrefs()
73 } catch (e) {
74 error = e instanceof ApiError ? e.message : 'Failed to save preferences'
75 } finally {
76 saving = false
77 }
78 }
79
80 const channels = [
81 { id: 'email', name: 'Email', description: 'Receive notifications via email' },
82 { id: 'discord', name: 'Discord', description: 'Receive notifications via Discord DM' },
83 { id: 'telegram', name: 'Telegram', description: 'Receive notifications via Telegram' },
84 { id: 'signal', name: 'Signal', description: 'Receive notifications via Signal' },
85 ]
86
87 function canSelectChannel(channelId: string): boolean {
88 if (channelId === 'email') return true
89 if (channelId === 'discord') return !!discordId
90 if (channelId === 'telegram') return !!telegramUsername
91 if (channelId === 'signal') return !!signalNumber
92 return false
93 }
94</script>
95
96<div class="page">
97 <header>
98 <a href="#/dashboard" class="back">← Dashboard</a>
99 <h1>Notification Preferences</h1>
100 </header>
101
102 <p class="description">
103 Choose how you want to receive important notifications like password resets,
104 security alerts, and account updates.
105 </p>
106
107 {#if loading}
108 <p class="loading">Loading...</p>
109 {:else}
110 {#if error}
111 <div class="message error">{error}</div>
112 {/if}
113
114 {#if success}
115 <div class="message success">{success}</div>
116 {/if}
117
118 <form onsubmit={handleSave}>
119 <section>
120 <h2>Preferred Channel</h2>
121 <p class="section-description">
122 Select your preferred way to receive notifications. You must configure a channel before you can select it.
123 </p>
124
125 <div class="channel-options">
126 {#each channels as channel}
127 <label class="channel-option" class:disabled={!canSelectChannel(channel.id)}>
128 <input
129 type="radio"
130 name="preferredChannel"
131 value={channel.id}
132 bind:group={preferredChannel}
133 disabled={!canSelectChannel(channel.id) || saving}
134 />
135 <div class="channel-info">
136 <span class="channel-name">{channel.name}</span>
137 <span class="channel-description">{channel.description}</span>
138 {#if channel.id !== 'email' && !canSelectChannel(channel.id)}
139 <span class="channel-hint">Configure below to enable</span>
140 {/if}
141 </div>
142 </label>
143 {/each}
144 </div>
145 </section>
146
147 <section>
148 <h2>Channel Configuration</h2>
149
150 <div class="channel-config">
151 <div class="config-item">
152 <label for="email">Email</label>
153 <div class="config-input">
154 <input
155 id="email"
156 type="email"
157 value={email}
158 disabled
159 class="readonly"
160 />
161 <span class="status verified">Primary</span>
162 </div>
163 <p class="config-hint">Your email is managed in Account Settings</p>
164 </div>
165
166 <div class="config-item">
167 <label for="discord">Discord User ID</label>
168 <div class="config-input">
169 <input
170 id="discord"
171 type="text"
172 bind:value={discordId}
173 placeholder="e.g., 123456789012345678"
174 disabled={saving}
175 />
176 {#if discordId}
177 {#if discordVerified}
178 <span class="status verified">Verified</span>
179 {:else}
180 <span class="status unverified">Not verified</span>
181 {/if}
182 {/if}
183 </div>
184 <p class="config-hint">Your Discord user ID (not username). Enable Developer Mode in Discord to copy it.</p>
185 </div>
186
187 <div class="config-item">
188 <label for="telegram">Telegram Username</label>
189 <div class="config-input">
190 <input
191 id="telegram"
192 type="text"
193 bind:value={telegramUsername}
194 placeholder="e.g., username"
195 disabled={saving}
196 />
197 {#if telegramUsername}
198 {#if telegramVerified}
199 <span class="status verified">Verified</span>
200 {:else}
201 <span class="status unverified">Not verified</span>
202 {/if}
203 {/if}
204 </div>
205 <p class="config-hint">Your Telegram username without the @ symbol</p>
206 </div>
207
208 <div class="config-item">
209 <label for="signal">Signal Phone Number</label>
210 <div class="config-input">
211 <input
212 id="signal"
213 type="tel"
214 bind:value={signalNumber}
215 placeholder="e.g., +1234567890"
216 disabled={saving}
217 />
218 {#if signalNumber}
219 {#if signalVerified}
220 <span class="status verified">Verified</span>
221 {:else}
222 <span class="status unverified">Not verified</span>
223 {/if}
224 {/if}
225 </div>
226 <p class="config-hint">Your Signal phone number with country code</p>
227 </div>
228 </div>
229 </section>
230
231 <div class="actions">
232 <button type="submit" disabled={saving}>
233 {saving ? 'Saving...' : 'Save Preferences'}
234 </button>
235 </div>
236 </form>
237 {/if}
238</div>
239
240<style>
241 .page {
242 max-width: 600px;
243 margin: 0 auto;
244 padding: 2rem;
245 }
246
247 header {
248 margin-bottom: 1rem;
249 }
250
251 .back {
252 color: var(--text-secondary);
253 text-decoration: none;
254 font-size: 0.875rem;
255 }
256
257 .back:hover {
258 color: var(--accent);
259 }
260
261 h1 {
262 margin: 0.5rem 0 0 0;
263 }
264
265 .description {
266 color: var(--text-secondary);
267 margin-bottom: 2rem;
268 }
269
270 .loading {
271 text-align: center;
272 color: var(--text-secondary);
273 padding: 2rem;
274 }
275
276 .message {
277 padding: 0.75rem;
278 border-radius: 4px;
279 margin-bottom: 1rem;
280 }
281
282 .message.error {
283 background: var(--error-bg);
284 border: 1px solid var(--error-border);
285 color: var(--error-text);
286 }
287
288 .message.success {
289 background: var(--success-bg);
290 border: 1px solid var(--success-border);
291 color: var(--success-text);
292 }
293
294 section {
295 background: var(--bg-secondary);
296 padding: 1.5rem;
297 border-radius: 8px;
298 margin-bottom: 1.5rem;
299 }
300
301 section h2 {
302 margin: 0 0 0.5rem 0;
303 font-size: 1.125rem;
304 }
305
306 .section-description {
307 color: var(--text-secondary);
308 font-size: 0.875rem;
309 margin: 0 0 1rem 0;
310 }
311
312 .channel-options {
313 display: flex;
314 flex-direction: column;
315 gap: 0.5rem;
316 }
317
318 .channel-option {
319 display: flex;
320 align-items: flex-start;
321 gap: 0.75rem;
322 padding: 0.75rem;
323 background: var(--bg-card);
324 border: 1px solid var(--border-color);
325 border-radius: 4px;
326 cursor: pointer;
327 transition: border-color 0.15s;
328 }
329
330 .channel-option:hover:not(.disabled) {
331 border-color: var(--accent);
332 }
333
334 .channel-option.disabled {
335 opacity: 0.6;
336 cursor: not-allowed;
337 }
338
339 .channel-option input {
340 margin-top: 0.25rem;
341 }
342
343 .channel-info {
344 display: flex;
345 flex-direction: column;
346 gap: 0.125rem;
347 }
348
349 .channel-name {
350 font-weight: 500;
351 }
352
353 .channel-description {
354 font-size: 0.875rem;
355 color: var(--text-secondary);
356 }
357
358 .channel-hint {
359 font-size: 0.75rem;
360 color: var(--text-muted);
361 font-style: italic;
362 }
363
364 .channel-config {
365 display: flex;
366 flex-direction: column;
367 gap: 1.25rem;
368 }
369
370 .config-item {
371 display: flex;
372 flex-direction: column;
373 gap: 0.25rem;
374 }
375
376 .config-item label {
377 font-size: 0.875rem;
378 font-weight: 500;
379 }
380
381 .config-input {
382 display: flex;
383 align-items: center;
384 gap: 0.5rem;
385 }
386
387 .config-input input {
388 flex: 1;
389 padding: 0.75rem;
390 border: 1px solid var(--border-color-light);
391 border-radius: 4px;
392 font-size: 1rem;
393 background: var(--bg-input);
394 color: var(--text-primary);
395 }
396
397 .config-input input:focus {
398 outline: none;
399 border-color: var(--accent);
400 }
401
402 .config-input input.readonly {
403 background: var(--bg-input-disabled);
404 color: var(--text-secondary);
405 }
406
407 .status {
408 padding: 0.25rem 0.5rem;
409 border-radius: 4px;
410 font-size: 0.75rem;
411 white-space: nowrap;
412 }
413
414 .status.verified {
415 background: var(--success-bg);
416 color: var(--success-text);
417 }
418
419 .status.unverified {
420 background: var(--warning-bg);
421 color: var(--warning-text);
422 }
423
424 .config-hint {
425 font-size: 0.75rem;
426 color: var(--text-secondary);
427 margin: 0;
428 }
429
430 .actions {
431 display: flex;
432 justify-content: flex-end;
433 }
434
435 .actions button {
436 padding: 0.75rem 2rem;
437 background: var(--accent);
438 color: white;
439 border: none;
440 border-radius: 4px;
441 font-size: 1rem;
442 cursor: pointer;
443 }
444
445 .actions button:hover:not(:disabled) {
446 background: var(--accent-hover);
447 }
448
449 .actions button:disabled {
450 opacity: 0.6;
451 cursor: not-allowed;
452 }
453</style>