this repo has no description
1<script lang="ts">
2 import { register, confirmSignup, resendVerification, getAuthState } from '../lib/auth.svelte'
3 import { navigate } from '../lib/router.svelte'
4 import { api, ApiError, type VerificationChannel } from '../lib/api'
5
6 let handle = $state('')
7 let email = $state('')
8 let password = $state('')
9 let confirmPassword = $state('')
10 let inviteCode = $state('')
11 let verificationChannel = $state<VerificationChannel>('email')
12 let discordId = $state('')
13 let telegramUsername = $state('')
14 let signalNumber = $state('')
15 let submitting = $state(false)
16 let error = $state<string | null>(null)
17
18 let pendingVerification = $state<{ did: string; handle: string; channel: string } | null>(null)
19 let verificationCode = $state('')
20 let resendingCode = $state(false)
21 let resendMessage = $state<string | null>(null)
22
23 let serverInfo = $state<{
24 availableUserDomains: string[]
25 inviteCodeRequired: boolean
26 } | null>(null)
27 let loadingServerInfo = $state(true)
28 let serverInfoLoaded = false
29
30 const auth = getAuthState()
31
32 $effect(() => {
33 if (auth.session) {
34 navigate('/dashboard')
35 }
36 })
37
38 $effect(() => {
39 if (!serverInfoLoaded) {
40 serverInfoLoaded = true
41 loadServerInfo()
42 }
43 })
44
45 async function loadServerInfo() {
46 try {
47 serverInfo = await api.describeServer()
48 } catch (e) {
49 console.error('Failed to load server info:', e)
50 } finally {
51 loadingServerInfo = false
52 }
53 }
54
55 function validateForm(): string | null {
56 if (!handle.trim()) return 'Handle is required'
57 if (!password) return 'Password is required'
58 if (password.length < 8) return 'Password must be at least 8 characters'
59 if (password !== confirmPassword) return 'Passwords do not match'
60 if (serverInfo?.inviteCodeRequired && !inviteCode.trim()) {
61 return 'Invite code is required'
62 }
63 switch (verificationChannel) {
64 case 'email':
65 if (!email.trim()) return 'Email is required for email verification'
66 break
67 case 'discord':
68 if (!discordId.trim()) return 'Discord ID is required for Discord verification'
69 break
70 case 'telegram':
71 if (!telegramUsername.trim()) return 'Telegram username is required for Telegram verification'
72 break
73 case 'signal':
74 if (!signalNumber.trim()) return 'Phone number is required for Signal verification'
75 break
76 }
77 return null
78 }
79
80 async function handleSubmit(e: Event) {
81 e.preventDefault()
82
83 const validationError = validateForm()
84 if (validationError) {
85 error = validationError
86 return
87 }
88
89 submitting = true
90 error = null
91
92 try {
93 const result = await register({
94 handle: handle.trim(),
95 email: email.trim(),
96 password,
97 inviteCode: inviteCode.trim() || undefined,
98 verificationChannel,
99 discordId: discordId.trim() || undefined,
100 telegramUsername: telegramUsername.trim() || undefined,
101 signalNumber: signalNumber.trim() || undefined,
102 })
103
104 if (result.verificationRequired) {
105 pendingVerification = {
106 did: result.did,
107 handle: result.handle,
108 channel: result.verificationChannel,
109 }
110 } else {
111 navigate('/dashboard')
112 }
113 } catch (err: any) {
114 if (err instanceof ApiError) {
115 error = err.message || 'Registration failed'
116 } else if (err instanceof Error) {
117 error = err.message || 'Registration failed'
118 } else {
119 error = 'Registration failed'
120 }
121 } finally {
122 submitting = false
123 }
124 }
125
126 async function handleVerification(e: Event) {
127 e.preventDefault()
128
129 if (!pendingVerification || !verificationCode.trim()) return
130
131 submitting = true
132 error = null
133
134 try {
135 await confirmSignup(pendingVerification.did, verificationCode.trim())
136 navigate('/dashboard')
137 } catch (e: any) {
138 error = e.message || 'Verification failed'
139 } finally {
140 submitting = false
141 }
142 }
143
144 async function handleResendCode() {
145 if (!pendingVerification || resendingCode) return
146
147 resendingCode = true
148 resendMessage = null
149 error = null
150
151 try {
152 await resendVerification(pendingVerification.did)
153 resendMessage = 'Verification code resent!'
154 } catch (e: any) {
155 error = e.message || 'Failed to resend code'
156 } finally {
157 resendingCode = false
158 }
159 }
160
161 let fullHandle = $derived(() => {
162 if (!handle.trim()) return ''
163 if (handle.includes('.')) return handle.trim()
164 const domain = serverInfo?.availableUserDomains?.[0]
165 if (domain) return `${handle.trim()}.${domain}`
166 return handle.trim()
167 })
168
169 function channelLabel(ch: string): string {
170 switch (ch) {
171 case 'email': return 'Email'
172 case 'discord': return 'Discord'
173 case 'telegram': return 'Telegram'
174 case 'signal': return 'Signal'
175 default: return ch
176 }
177 }
178</script>
179
180<div class="register-container">
181 {#if error}
182 <div class="error">{error}</div>
183 {/if}
184
185 {#if pendingVerification}
186 <h1>Verify Your Account</h1>
187 <p class="subtitle">
188 We've sent a verification code to your {channelLabel(pendingVerification.channel)}.
189 Enter it below to complete registration.
190 </p>
191
192 {#if resendMessage}
193 <div class="success">{resendMessage}</div>
194 {/if}
195
196 <form onsubmit={(e) => { e.preventDefault(); handleVerification(e); }}>
197
198 <div class="field">
199 <label for="verification-code">Verification Code</label>
200 <input
201 id="verification-code"
202 type="text"
203 bind:value={verificationCode}
204 placeholder="Enter 6-digit code"
205 disabled={submitting}
206 required
207 maxlength="6"
208 inputmode="numeric"
209 autocomplete="one-time-code"
210 />
211 </div>
212
213 <button type="submit" disabled={submitting || !verificationCode.trim()}>
214 {submitting ? 'Verifying...' : 'Verify Account'}
215 </button>
216
217 <button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}>
218 {resendingCode ? 'Resending...' : 'Resend Code'}
219 </button>
220 </form>
221 {:else}
222 <h1>Create Account</h1>
223 <p class="subtitle">Create a new account on this PDS</p>
224
225 {#if loadingServerInfo}
226 <p class="loading">Loading...</p>
227 {:else}
228 <form onsubmit={(e) => { e.preventDefault(); handleSubmit(e); }}>
229 <div class="field">
230 <label for="handle">Handle</label>
231 <input
232 id="handle"
233 type="text"
234 bind:value={handle}
235 placeholder="yourname"
236 disabled={submitting}
237 required
238 />
239 {#if fullHandle()}
240 <p class="hint">Your full handle will be: @{fullHandle()}</p>
241 {/if}
242 </div>
243
244 <div class="field">
245 <label for="password">Password</label>
246 <input
247 id="password"
248 type="password"
249 bind:value={password}
250 placeholder="At least 8 characters"
251 disabled={submitting}
252 required
253 minlength="8"
254 />
255 </div>
256
257 <div class="field">
258 <label for="confirm-password">Confirm Password</label>
259 <input
260 id="confirm-password"
261 type="password"
262 bind:value={confirmPassword}
263 placeholder="Confirm your password"
264 disabled={submitting}
265 required
266 />
267 </div>
268
269 <fieldset class="verification-section">
270 <legend>Contact Method</legend>
271 <p class="section-hint">Choose how you'd like to verify your account and receive notifications. You only need one.</p>
272
273 <div class="field">
274 <label for="verification-channel">Verification Method</label>
275 <select
276 id="verification-channel"
277 bind:value={verificationChannel}
278 disabled={submitting}
279 >
280 <option value="email">Email</option>
281 <option value="discord">Discord</option>
282 <option value="telegram">Telegram</option>
283 <option value="signal">Signal</option>
284 </select>
285 </div>
286
287 {#if verificationChannel === 'email'}
288 <div class="field">
289 <label for="email">Email Address</label>
290 <input
291 id="email"
292 type="email"
293 bind:value={email}
294 placeholder="you@example.com"
295 disabled={submitting}
296 required
297 />
298 </div>
299 {:else if verificationChannel === 'discord'}
300 <div class="field">
301 <label for="discord-id">Discord User ID</label>
302 <input
303 id="discord-id"
304 type="text"
305 bind:value={discordId}
306 placeholder="Your Discord user ID"
307 disabled={submitting}
308 required
309 />
310 <p class="hint">Your numeric Discord user ID (enable Developer Mode to find it)</p>
311 </div>
312 {:else if verificationChannel === 'telegram'}
313 <div class="field">
314 <label for="telegram-username">Telegram Username</label>
315 <input
316 id="telegram-username"
317 type="text"
318 bind:value={telegramUsername}
319 placeholder="@yourusername"
320 disabled={submitting}
321 required
322 />
323 </div>
324 {:else if verificationChannel === 'signal'}
325 <div class="field">
326 <label for="signal-number">Signal Phone Number</label>
327 <input
328 id="signal-number"
329 type="tel"
330 bind:value={signalNumber}
331 placeholder="+1234567890"
332 disabled={submitting}
333 required
334 />
335 <p class="hint">Include country code (e.g., +1 for US)</p>
336 </div>
337 {/if}
338 </fieldset>
339
340 {#if serverInfo?.inviteCodeRequired}
341 <div class="field">
342 <label for="invite-code">Invite Code <span class="required">*</span></label>
343 <input
344 id="invite-code"
345 type="text"
346 bind:value={inviteCode}
347 placeholder="Enter your invite code"
348 disabled={submitting}
349 required
350 />
351 </div>
352 {:else}
353 <div class="field optional">
354 <label for="invite-code">Invite Code <span class="optional-label">(optional)</span></label>
355 <input
356 id="invite-code"
357 type="text"
358 bind:value={inviteCode}
359 placeholder="Enter invite code if you have one"
360 disabled={submitting}
361 />
362 </div>
363 {/if}
364
365 <button type="submit" disabled={submitting}>
366 {submitting ? 'Creating account...' : 'Create Account'}
367 </button>
368 </form>
369
370 <p class="login-link">
371 Already have an account? <a href="#/login">Sign in</a>
372 </p>
373 {/if}
374 {/if}
375</div>
376
377<style>
378 .register-container {
379 max-width: 400px;
380 margin: 4rem auto;
381 padding: 2rem;
382 }
383
384 h1 {
385 margin: 0 0 0.5rem 0;
386 }
387
388 .subtitle {
389 color: var(--text-secondary);
390 margin: 0 0 2rem 0;
391 }
392
393 .loading {
394 text-align: center;
395 color: var(--text-secondary);
396 }
397
398 form {
399 display: flex;
400 flex-direction: column;
401 gap: 1rem;
402 }
403
404 .field {
405 display: flex;
406 flex-direction: column;
407 gap: 0.25rem;
408 }
409
410 .field.optional {
411 opacity: 0.8;
412 }
413
414 label {
415 font-size: 0.875rem;
416 font-weight: 500;
417 }
418
419 .required {
420 color: var(--error-text);
421 }
422
423 .optional-label {
424 color: var(--text-secondary);
425 font-weight: normal;
426 }
427
428 input, select {
429 padding: 0.75rem;
430 border: 1px solid var(--border-color-light);
431 border-radius: 4px;
432 font-size: 1rem;
433 background: var(--bg-input);
434 color: var(--text-primary);
435 }
436
437 input:focus, select:focus {
438 outline: none;
439 border-color: var(--accent);
440 }
441
442 .hint {
443 font-size: 0.75rem;
444 color: var(--text-secondary);
445 margin: 0.25rem 0 0 0;
446 }
447
448 .verification-section {
449 border: 1px solid var(--border-color-light);
450 border-radius: 6px;
451 padding: 1rem;
452 margin: 0.5rem 0;
453 }
454
455 .verification-section legend {
456 font-weight: 600;
457 padding: 0 0.5rem;
458 color: var(--text-primary);
459 }
460
461 .section-hint {
462 font-size: 0.8rem;
463 color: var(--text-secondary);
464 margin: 0 0 1rem 0;
465 }
466
467 button {
468 padding: 0.75rem;
469 background: var(--accent);
470 color: white;
471 border: none;
472 border-radius: 4px;
473 font-size: 1rem;
474 cursor: pointer;
475 margin-top: 0.5rem;
476 }
477
478 button:hover:not(:disabled) {
479 background: var(--accent-hover);
480 }
481
482 button:disabled {
483 opacity: 0.6;
484 cursor: not-allowed;
485 }
486
487 button.secondary {
488 background: transparent;
489 color: var(--accent);
490 border: 1px solid var(--accent);
491 }
492
493 button.secondary:hover:not(:disabled) {
494 background: var(--accent);
495 color: white;
496 }
497
498 .error {
499 padding: 0.75rem;
500 background: var(--error-bg);
501 border: 1px solid var(--error-border);
502 border-radius: 4px;
503 color: var(--error-text);
504 }
505
506 .success {
507 padding: 0.75rem;
508 background: var(--success-bg);
509 border: 1px solid var(--success-border);
510 border-radius: 4px;
511 color: var(--success-text);
512 }
513
514 .login-link {
515 text-align: center;
516 margin-top: 1.5rem;
517 color: var(--text-secondary);
518 }
519
520 .login-link a {
521 color: var(--accent);
522 }
523</style>