this repo has no description
1<script lang="ts"> 2 import { navigate } from '../lib/router.svelte' 3 import { api, ApiError } from '../lib/api' 4 import { _ } from '../lib/i18n' 5 import { 6 createRegistrationFlow, 7 VerificationStep, 8 KeyChoiceStep, 9 DidDocStep, 10 } from '../lib/registration' 11 12 let serverInfo = $state<{ 13 availableUserDomains: string[] 14 inviteCodeRequired: boolean 15 availableCommsChannels?: string[] 16 } | null>(null) 17 let loadingServerInfo = $state(true) 18 let serverInfoLoaded = false 19 20 let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null) 21 let confirmPassword = $state('') 22 23 $effect(() => { 24 if (!serverInfoLoaded) { 25 serverInfoLoaded = true 26 loadServerInfo() 27 } 28 }) 29 30 $effect(() => { 31 if (flow?.state.step === 'redirect-to-dashboard') { 32 navigate('/dashboard') 33 } 34 }) 35 36 let creatingStarted = false 37 $effect(() => { 38 if (flow?.state.step === 'creating' && !creatingStarted) { 39 creatingStarted = true 40 flow.createPasswordAccount() 41 } 42 }) 43 44 async function loadServerInfo() { 45 try { 46 serverInfo = await api.describeServer() 47 const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname 48 flow = createRegistrationFlow('password', hostname) 49 } catch (e) { 50 console.error('Failed to load server info:', e) 51 } finally { 52 loadingServerInfo = false 53 } 54 } 55 56 function validateInfoStep(): string | null { 57 if (!flow) return 'Flow not initialized' 58 const info = flow.info 59 if (!info.handle.trim()) return $_('register.validation.handleRequired') 60 if (info.handle.includes('.')) return $_('register.validation.handleNoDots') 61 if (!info.password) return $_('register.validation.passwordRequired') 62 if (info.password.length < 8) return $_('register.validation.passwordLength') 63 if (info.password !== confirmPassword) return $_('register.validation.passwordsMismatch') 64 if (serverInfo?.inviteCodeRequired && !info.inviteCode?.trim()) { 65 return $_('register.validation.inviteCodeRequired') 66 } 67 if (info.didType === 'web-external') { 68 if (!info.externalDid?.trim()) return $_('register.validation.externalDidRequired') 69 if (!info.externalDid.trim().startsWith('did:web:')) return $_('register.validation.externalDidFormat') 70 } 71 switch (info.verificationChannel) { 72 case 'email': 73 if (!info.email.trim()) return $_('register.validation.emailRequired') 74 break 75 case 'discord': 76 if (!info.discordId?.trim()) return $_('register.validation.discordIdRequired') 77 break 78 case 'telegram': 79 if (!info.telegramUsername?.trim()) return $_('register.validation.telegramRequired') 80 break 81 case 'signal': 82 if (!info.signalNumber?.trim()) return $_('register.validation.signalRequired') 83 break 84 } 85 return null 86 } 87 88 async function handleInfoSubmit(e: Event) { 89 e.preventDefault() 90 if (!flow) return 91 92 const validationError = validateInfoStep() 93 if (validationError) { 94 flow.setError(validationError) 95 return 96 } 97 98 flow.clearError() 99 flow.proceedFromInfo() 100 } 101 102 async function handleCreateAccount() { 103 if (!flow) return 104 await flow.createPasswordAccount() 105 } 106 107 async function handleComplete() { 108 if (flow) { 109 await flow.finalizeSession() 110 } 111 navigate('/dashboard') 112 } 113 114 function isChannelAvailable(ch: string): boolean { 115 const available = serverInfo?.availableCommsChannels ?? ['email'] 116 return available.includes(ch) 117 } 118 119 function channelLabel(ch: string): string { 120 switch (ch) { 121 case 'email': return $_('register.email') 122 case 'discord': return $_('register.discord') 123 case 'telegram': return $_('register.telegram') 124 case 'signal': return $_('register.signal') 125 default: return ch 126 } 127 } 128 129 let fullHandle = $derived(() => { 130 if (!flow?.info.handle.trim()) return '' 131 if (flow.info.handle.includes('.')) return flow.info.handle.trim() 132 const domain = serverInfo?.availableUserDomains?.[0] 133 if (domain) return `${flow.info.handle.trim()}.${domain}` 134 return flow.info.handle.trim() 135 }) 136 137 function extractDomain(did: string): string { 138 return did.replace('did:web:', '').replace(/%3A/g, ':') 139 } 140 141 function getSubtitle(): string { 142 if (!flow) return '' 143 switch (flow.state.step) { 144 case 'info': return $_('register.subtitle') 145 case 'key-choice': return 'Choose how to set up your external did:web identity.' 146 case 'initial-did-doc': return 'Upload your DID document to continue.' 147 case 'creating': return $_('register.creating') 148 case 'verify': return `Verify your ${channelLabel(flow.info.verificationChannel)} to continue.` 149 case 'updated-did-doc': return 'Update your DID document with the PDS signing key.' 150 case 'activating': return 'Activating your account...' 151 case 'redirect-to-dashboard': return 'Your account has been created successfully!' 152 default: return '' 153 } 154 } 155</script> 156 157<div class="register-page"> 158 {#if flow?.state.step === 'info'} 159 <div class="migrate-callout"> 160 <div class="migrate-icon"></div> 161 <div class="migrate-content"> 162 <strong>{$_('register.migrateTitle')}</strong> 163 <p>{$_('register.migrateDescription')}</p> 164 <a href="https://pdsmoover.com/moover" target="_blank" rel="noopener" class="migrate-link"> 165 {$_('register.migrateLink')}166 </a> 167 </div> 168 </div> 169 {/if} 170 171 <h1>{$_('register.title')}</h1> 172 <p class="subtitle">{getSubtitle()}</p> 173 174 {#if flow?.state.error} 175 <div class="message error">{flow.state.error}</div> 176 {/if} 177 178 {#if loadingServerInfo || !flow} 179 <p class="loading">{$_('common.loading')}</p> 180 181 {:else if flow.state.step === 'info'} 182 <form onsubmit={handleInfoSubmit}> 183 <div class="field"> 184 <label for="handle">{$_('register.handle')}</label> 185 <input 186 id="handle" 187 type="text" 188 bind:value={flow.info.handle} 189 placeholder={$_('register.handlePlaceholder')} 190 disabled={flow.state.submitting} 191 required 192 /> 193 {#if flow.info.handle.includes('.')} 194 <p class="hint warning">{$_('register.handleDotWarning')}</p> 195 {:else if fullHandle()} 196 <p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p> 197 {/if} 198 </div> 199 200 <div class="field"> 201 <label for="password">{$_('register.password')}</label> 202 <input 203 id="password" 204 type="password" 205 bind:value={flow.info.password} 206 placeholder={$_('register.passwordPlaceholder')} 207 disabled={flow.state.submitting} 208 required 209 minlength="8" 210 /> 211 </div> 212 213 <div class="field"> 214 <label for="confirm-password">{$_('register.confirmPassword')}</label> 215 <input 216 id="confirm-password" 217 type="password" 218 bind:value={confirmPassword} 219 placeholder={$_('register.confirmPasswordPlaceholder')} 220 disabled={flow.state.submitting} 221 required 222 /> 223 </div> 224 225 <fieldset class="section-fieldset"> 226 <legend>{$_('register.identityType')}</legend> 227 <p class="section-hint">{$_('register.identityHint')}</p> 228 229 <div class="radio-group"> 230 <label class="radio-label"> 231 <input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 232 <span class="radio-content"> 233 <strong>{$_('register.didPlc')}</strong> {$_('register.didPlcRecommended')} 234 <span class="radio-hint">{$_('register.didPlcHint')}</span> 235 </span> 236 </label> 237 238 <label class="radio-label"> 239 <input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 240 <span class="radio-content"> 241 <strong>{$_('register.didWeb')}</strong> 242 <span class="radio-hint">{$_('register.didWebHint')}</span> 243 </span> 244 </label> 245 246 <label class="radio-label"> 247 <input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 248 <span class="radio-content"> 249 <strong>{$_('register.didWebBYOD')}</strong> 250 <span class="radio-hint">{$_('register.didWebBYODHint')}</span> 251 </span> 252 </label> 253 </div> 254 255 {#if flow.info.didType === 'web'} 256 <div class="warning-box"> 257 <strong>{$_('register.didWebWarningTitle')}</strong> 258 <ul> 259 <li><strong>{$_('register.didWebWarning1')}</strong> {$_('register.didWebWarning1Detail', { values: { did: `did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}` } })}</li> 260 <li><strong>{$_('register.didWebWarning2')}</strong> {$_('register.didWebWarning2Detail')}</li> 261 <li><strong>{$_('register.didWebWarning3')}</strong> {$_('register.didWebWarning3Detail')}</li> 262 <li><strong>{$_('register.didWebWarning4')}</strong> {$_('register.didWebWarning4Detail')}</li> 263 </ul> 264 </div> 265 {/if} 266 267 {#if flow.info.didType === 'web-external'} 268 <div class="field"> 269 <label for="external-did">{$_('register.externalDid')}</label> 270 <input 271 id="external-did" 272 type="text" 273 bind:value={flow.info.externalDid} 274 placeholder={$_('register.externalDidPlaceholder')} 275 disabled={flow.state.submitting} 276 required 277 /> 278 <p class="hint">{$_('register.externalDidHint')}</p> 279 </div> 280 {/if} 281 </fieldset> 282 283 <fieldset class="section-fieldset"> 284 <legend>{$_('register.contactMethod')}</legend> 285 <p class="section-hint">{$_('register.contactMethodHint')}</p> 286 287 <div class="field"> 288 <label for="verification-channel">{$_('register.verificationMethod')}</label> 289 <select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}> 290 <option value="email">{$_('register.email')}</option> 291 <option value="discord" disabled={!isChannelAvailable('discord')}> 292 {$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`} 293 </option> 294 <option value="telegram" disabled={!isChannelAvailable('telegram')}> 295 {$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`} 296 </option> 297 <option value="signal" disabled={!isChannelAvailable('signal')}> 298 {$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`} 299 </option> 300 </select> 301 </div> 302 303 {#if flow.info.verificationChannel === 'email'} 304 <div class="field"> 305 <label for="email">{$_('register.emailAddress')}</label> 306 <input 307 id="email" 308 type="email" 309 bind:value={flow.info.email} 310 placeholder={$_('register.emailPlaceholder')} 311 disabled={flow.state.submitting} 312 required 313 /> 314 </div> 315 {:else if flow.info.verificationChannel === 'discord'} 316 <div class="field"> 317 <label for="discord-id">{$_('register.discordId')}</label> 318 <input 319 id="discord-id" 320 type="text" 321 bind:value={flow.info.discordId} 322 placeholder={$_('register.discordIdPlaceholder')} 323 disabled={flow.state.submitting} 324 required 325 /> 326 <p class="hint">{$_('register.discordIdHint')}</p> 327 </div> 328 {:else if flow.info.verificationChannel === 'telegram'} 329 <div class="field"> 330 <label for="telegram-username">{$_('register.telegramUsername')}</label> 331 <input 332 id="telegram-username" 333 type="text" 334 bind:value={flow.info.telegramUsername} 335 placeholder={$_('register.telegramUsernamePlaceholder')} 336 disabled={flow.state.submitting} 337 required 338 /> 339 </div> 340 {:else if flow.info.verificationChannel === 'signal'} 341 <div class="field"> 342 <label for="signal-number">{$_('register.signalNumber')}</label> 343 <input 344 id="signal-number" 345 type="tel" 346 bind:value={flow.info.signalNumber} 347 placeholder={$_('register.signalNumberPlaceholder')} 348 disabled={flow.state.submitting} 349 required 350 /> 351 <p class="hint">{$_('register.signalNumberHint')}</p> 352 </div> 353 {/if} 354 </fieldset> 355 356 {#if serverInfo?.inviteCodeRequired} 357 <div class="field"> 358 <label for="invite-code">{$_('register.inviteCode')} <span class="required">{$_('register.inviteCodeRequired')}</span></label> 359 <input 360 id="invite-code" 361 type="text" 362 bind:value={flow.info.inviteCode} 363 placeholder={$_('register.inviteCodePlaceholder')} 364 disabled={flow.state.submitting} 365 required 366 /> 367 </div> 368 {/if} 369 370 <button type="submit" disabled={flow.state.submitting}> 371 {flow.state.submitting ? $_('register.creating') : $_('register.createButton')} 372 </button> 373 </form> 374 375 <p class="link-text"> 376 {$_('register.alreadyHaveAccount')} <a href="#/login">{$_('register.signIn')}</a> 377 </p> 378 <p class="link-text"> 379 {$_('register.wantPasswordless')} <a href="#/register-passkey">{$_('register.createPasskeyAccount')}</a> 380 </p> 381 382 {:else if flow.state.step === 'key-choice'} 383 <KeyChoiceStep {flow} /> 384 385 {:else if flow.state.step === 'initial-did-doc'} 386 <DidDocStep 387 {flow} 388 type="initial" 389 onConfirm={handleCreateAccount} 390 onBack={() => flow?.goBack()} 391 /> 392 393 {:else if flow.state.step === 'creating'} 394 <p class="loading">{$_('register.creating')}</p> 395 396 {:else if flow.state.step === 'verify'} 397 <VerificationStep {flow} /> 398 399 {:else if flow.state.step === 'updated-did-doc'} 400 <DidDocStep 401 {flow} 402 type="updated" 403 onConfirm={() => flow?.activateAccount()} 404 /> 405 406 {:else if flow.state.step === 'redirect-to-dashboard'} 407 <p class="loading">Redirecting to dashboard...</p> 408 {/if} 409</div> 410 411<style> 412 .register-page { 413 max-width: var(--width-sm); 414 margin: var(--space-9) auto; 415 padding: var(--space-7); 416 } 417 418 .migrate-callout { 419 display: flex; 420 gap: var(--space-4); 421 padding: var(--space-5); 422 background: var(--accent-muted); 423 border: 1px solid var(--accent); 424 border-radius: var(--radius-xl); 425 margin-bottom: var(--space-6); 426 } 427 428 .migrate-icon { 429 font-size: var(--text-2xl); 430 line-height: 1; 431 color: var(--accent); 432 } 433 434 .migrate-content { 435 flex: 1; 436 } 437 438 .migrate-content strong { 439 display: block; 440 color: var(--text-primary); 441 margin-bottom: var(--space-2); 442 } 443 444 .migrate-content p { 445 margin: 0 0 var(--space-3) 0; 446 font-size: var(--text-sm); 447 color: var(--text-secondary); 448 line-height: var(--leading-relaxed); 449 } 450 451 .migrate-link { 452 font-size: var(--text-sm); 453 font-weight: var(--font-medium); 454 color: var(--accent); 455 text-decoration: none; 456 } 457 458 .migrate-link:hover { 459 text-decoration: underline; 460 } 461 462 h1 { 463 margin: 0 0 var(--space-3) 0; 464 } 465 466 .subtitle { 467 color: var(--text-secondary); 468 margin: 0 0 var(--space-7) 0; 469 } 470 471 .loading { 472 text-align: center; 473 color: var(--text-secondary); 474 } 475 476 form { 477 display: flex; 478 flex-direction: column; 479 gap: var(--space-5); 480 } 481 482 .required { 483 color: var(--error-text); 484 } 485 486 .section-fieldset { 487 border: 1px solid var(--border-color); 488 border-radius: var(--radius-lg); 489 padding: var(--space-5); 490 } 491 492 .section-fieldset legend { 493 font-weight: var(--font-semibold); 494 padding: 0 var(--space-3); 495 } 496 497 .section-hint { 498 font-size: var(--text-sm); 499 color: var(--text-secondary); 500 margin: 0 0 var(--space-5) 0; 501 } 502 503 .radio-group { 504 display: flex; 505 flex-direction: column; 506 gap: var(--space-4); 507 } 508 509 .radio-label { 510 display: flex; 511 align-items: flex-start; 512 gap: var(--space-3); 513 cursor: pointer; 514 font-size: var(--text-base); 515 font-weight: var(--font-normal); 516 margin-bottom: 0; 517 } 518 519 .radio-label input[type="radio"] { 520 margin-top: var(--space-1); 521 width: auto; 522 } 523 524 .radio-content { 525 display: flex; 526 flex-direction: column; 527 gap: var(--space-1); 528 } 529 530 .radio-hint { 531 font-size: var(--text-xs); 532 color: var(--text-secondary); 533 } 534 535 .warning-box { 536 margin-top: var(--space-5); 537 padding: var(--space-5); 538 background: var(--warning-bg); 539 border: 1px solid var(--warning-border); 540 border-radius: var(--radius-lg); 541 font-size: var(--text-sm); 542 } 543 544 .warning-box strong { 545 color: var(--warning-text); 546 } 547 548 .warning-box ul { 549 margin: var(--space-4) 0 0 0; 550 padding-left: var(--space-5); 551 } 552 553 .warning-box li { 554 margin-bottom: var(--space-3); 555 line-height: var(--leading-normal); 556 } 557 558 .warning-box li:last-child { 559 margin-bottom: 0; 560 } 561 562 button[type="submit"] { 563 margin-top: var(--space-3); 564 } 565 566 .link-text { 567 text-align: center; 568 margin-top: var(--space-6); 569 color: var(--text-secondary); 570 } 571 572 .link-text a { 573 color: var(--accent); 574 } 575</style>