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