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