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