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