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 <p class="login-link"> 346 Want passwordless security? <a href="#/register-passkey">Create a passkey account</a> 347 </p> 348 {/if} 349</div> 350<style> 351 .register-container { 352 max-width: 400px; 353 margin: 4rem auto; 354 padding: 2rem; 355 } 356 h1 { 357 margin: 0 0 0.5rem 0; 358 } 359 .subtitle { 360 color: var(--text-secondary); 361 margin: 0 0 2rem 0; 362 } 363 .loading { 364 text-align: center; 365 color: var(--text-secondary); 366 } 367 form { 368 display: flex; 369 flex-direction: column; 370 gap: 1rem; 371 } 372 .field { 373 display: flex; 374 flex-direction: column; 375 gap: 0.25rem; 376 } 377 label { 378 font-size: 0.875rem; 379 font-weight: 500; 380 } 381 .required { 382 color: var(--error-text); 383 } 384 input, select { 385 padding: 0.75rem; 386 border: 1px solid var(--border-color-light); 387 border-radius: 4px; 388 font-size: 1rem; 389 background: var(--bg-input); 390 color: var(--text-primary); 391 } 392 input:focus, select:focus { 393 outline: none; 394 border-color: var(--accent); 395 } 396 .hint { 397 font-size: 0.75rem; 398 color: var(--text-secondary); 399 margin: 0.25rem 0 0 0; 400 } 401 .hint.warning { 402 color: var(--warning-text, #856404); 403 } 404 .verification-section { 405 border: 1px solid var(--border-color-light); 406 border-radius: 6px; 407 padding: 1rem; 408 margin: 0.5rem 0; 409 } 410 .verification-section legend { 411 font-weight: 600; 412 padding: 0 0.5rem; 413 color: var(--text-primary); 414 } 415 .identity-section { 416 border: 1px solid var(--border-color-light); 417 border-radius: 6px; 418 padding: 1rem; 419 margin: 0.5rem 0; 420 } 421 .identity-section legend { 422 font-weight: 600; 423 padding: 0 0.5rem; 424 color: var(--text-primary); 425 } 426 .radio-group { 427 display: flex; 428 flex-direction: column; 429 gap: 0.75rem; 430 } 431 .radio-label { 432 display: flex; 433 align-items: flex-start; 434 gap: 0.5rem; 435 cursor: pointer; 436 } 437 .radio-label input[type="radio"] { 438 margin-top: 0.25rem; 439 } 440 .radio-content { 441 display: flex; 442 flex-direction: column; 443 gap: 0.125rem; 444 } 445 .radio-hint { 446 font-size: 0.75rem; 447 color: var(--text-secondary); 448 } 449 .section-hint { 450 font-size: 0.8rem; 451 color: var(--text-secondary); 452 margin: 0 0 1rem 0; 453 } 454 .did-web-warning { 455 margin-top: 1rem; 456 padding: 1rem; 457 background: var(--warning-bg, #fff3cd); 458 border: 1px solid var(--warning-border, #ffc107); 459 border-radius: 6px; 460 font-size: 0.875rem; 461 } 462 .did-web-warning strong { 463 color: var(--warning-text, #856404); 464 } 465 .did-web-warning ul { 466 margin: 0.75rem 0 0 0; 467 padding-left: 1.25rem; 468 } 469 .did-web-warning li { 470 margin-bottom: 0.5rem; 471 line-height: 1.4; 472 } 473 .did-web-warning li:last-child { 474 margin-bottom: 0; 475 } 476 .did-web-warning code { 477 background: rgba(0, 0, 0, 0.1); 478 padding: 0.125rem 0.25rem; 479 border-radius: 3px; 480 font-size: 0.8rem; 481 } 482 button { 483 padding: 0.75rem; 484 background: var(--accent); 485 color: white; 486 border: none; 487 border-radius: 4px; 488 font-size: 1rem; 489 cursor: pointer; 490 margin-top: 0.5rem; 491 } 492 button:hover:not(:disabled) { 493 background: var(--accent-hover); 494 } 495 button:disabled { 496 opacity: 0.6; 497 cursor: not-allowed; 498 } 499 .error { 500 padding: 0.75rem; 501 background: var(--error-bg); 502 border: 1px solid var(--error-border); 503 border-radius: 4px; 504 color: var(--error-text); 505 } 506 .login-link { 507 text-align: center; 508 margin-top: 1.5rem; 509 color: var(--text-secondary); 510 } 511 .login-link a { 512 color: var(--accent); 513 } 514</style>