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>