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 $_('register.subtitleKeyChoice') 146 case 'initial-did-doc': return $_('register.subtitleInitialDidDoc') 147 case 'creating': return $_('register.creating') 148 case 'verify': return $_('register.subtitleVerify', { values: { channel: channelLabel(flow.info.verificationChannel) } }) 149 case 'updated-did-doc': return $_('register.subtitleUpdatedDidDoc') 150 case 'activating': return $_('register.subtitleActivating') 151 case 'redirect-to-dashboard': return $_('register.subtitleComplete') 152 default: return '' 153 } 154 } 155</script> 156 157<div class="register-page"> 158 <header class="page-header"> 159 <h1>{$_('register.title')}</h1> 160 <p class="subtitle">{getSubtitle()}</p> 161 </header> 162 163 {#if flow?.state.error} 164 <div class="message error">{flow.state.error}</div> 165 {/if} 166 167 {#if loadingServerInfo || !flow} 168 <p class="loading">{$_('common.loading')}</p> 169 170 {:else if flow.state.step === 'info'} 171 <div class="migrate-callout"> 172 <div class="migrate-icon"></div> 173 <div class="migrate-content"> 174 <strong>{$_('register.migrateTitle')}</strong> 175 <p>{$_('register.migrateDescription')}</p> 176 <a href="https://pdsmoover.com/moover" target="_blank" rel="noopener" class="migrate-link"> 177 {$_('register.migrateLink')}178 </a> 179 </div> 180 </div> 181 182 <div class="split-layout sidebar-right"> 183 <div class="form-section"> 184 <form onsubmit={handleInfoSubmit}> 185 <div class="field"> 186 <label for="handle">{$_('register.handle')}</label> 187 <input 188 id="handle" 189 type="text" 190 bind:value={flow.info.handle} 191 placeholder={$_('register.handlePlaceholder')} 192 disabled={flow.state.submitting} 193 required 194 /> 195 {#if flow.info.handle.includes('.')} 196 <p class="hint warning">{$_('register.handleDotWarning')}</p> 197 {:else if fullHandle()} 198 <p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p> 199 {/if} 200 </div> 201 202 <div class="form-row"> 203 <div class="field"> 204 <label for="password">{$_('register.password')}</label> 205 <input 206 id="password" 207 type="password" 208 bind:value={flow.info.password} 209 placeholder={$_('register.passwordPlaceholder')} 210 disabled={flow.state.submitting} 211 required 212 minlength="8" 213 /> 214 </div> 215 216 <div class="field"> 217 <label for="confirm-password">{$_('register.confirmPassword')}</label> 218 <input 219 id="confirm-password" 220 type="password" 221 bind:value={confirmPassword} 222 placeholder={$_('register.confirmPasswordPlaceholder')} 223 disabled={flow.state.submitting} 224 required 225 /> 226 </div> 227 </div> 228 229 <fieldset class="section-fieldset"> 230 <legend>{$_('register.identityType')}</legend> 231 <div class="radio-group"> 232 <label class="radio-label"> 233 <input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 234 <span class="radio-content"> 235 <strong>{$_('register.didPlc')}</strong> {$_('register.didPlcRecommended')} 236 <span class="radio-hint">{$_('register.didPlcHint')}</span> 237 </span> 238 </label> 239 240 <label class="radio-label"> 241 <input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 242 <span class="radio-content"> 243 <strong>{$_('register.didWeb')}</strong> 244 <span class="radio-hint">{$_('register.didWebHint')}</span> 245 </span> 246 </label> 247 248 <label class="radio-label"> 249 <input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 250 <span class="radio-content"> 251 <strong>{$_('register.didWebBYOD')}</strong> 252 <span class="radio-hint">{$_('register.didWebBYODHint')}</span> 253 </span> 254 </label> 255 </div> 256 257 {#if flow.info.didType === 'web'} 258 <div class="warning-box"> 259 <strong>{$_('register.didWebWarningTitle')}</strong> 260 <ul> 261 <li><strong>{$_('register.didWebWarning1')}</strong> {$_('register.didWebWarning1Detail', { values: { did: `did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}` } })}</li> 262 <li><strong>{$_('register.didWebWarning2')}</strong> {$_('register.didWebWarning2Detail')}</li> 263 <li><strong>{$_('register.didWebWarning3')}</strong> {$_('register.didWebWarning3Detail')}</li> 264 <li><strong>{$_('register.didWebWarning4')}</strong> {$_('register.didWebWarning4Detail')}</li> 265 </ul> 266 </div> 267 {/if} 268 269 {#if flow.info.didType === 'web-external'} 270 <div class="field"> 271 <label for="external-did">{$_('register.externalDid')}</label> 272 <input 273 id="external-did" 274 type="text" 275 bind:value={flow.info.externalDid} 276 placeholder={$_('register.externalDidPlaceholder')} 277 disabled={flow.state.submitting} 278 required 279 /> 280 <p class="hint">{$_('register.externalDidHint')}</p> 281 </div> 282 {/if} 283 </fieldset> 284 285 <fieldset class="section-fieldset"> 286 <legend>{$_('register.contactMethod')}</legend> 287 <div class="contact-fields"> 288 <div class="field"> 289 <label for="verification-channel">{$_('register.verificationMethod')}</label> 290 <select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}> 291 <option value="email">{$_('register.email')}</option> 292 <option value="discord" disabled={!isChannelAvailable('discord')}> 293 {$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`} 294 </option> 295 <option value="telegram" disabled={!isChannelAvailable('telegram')}> 296 {$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`} 297 </option> 298 <option value="signal" disabled={!isChannelAvailable('signal')}> 299 {$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`} 300 </option> 301 </select> 302 </div> 303 304 {#if flow.info.verificationChannel === 'email'} 305 <div class="field"> 306 <label for="email">{$_('register.emailAddress')}</label> 307 <input 308 id="email" 309 type="email" 310 bind:value={flow.info.email} 311 placeholder={$_('register.emailPlaceholder')} 312 disabled={flow.state.submitting} 313 required 314 /> 315 </div> 316 {:else if flow.info.verificationChannel === 'discord'} 317 <div class="field"> 318 <label for="discord-id">{$_('register.discordId')}</label> 319 <input 320 id="discord-id" 321 type="text" 322 bind:value={flow.info.discordId} 323 placeholder={$_('register.discordIdPlaceholder')} 324 disabled={flow.state.submitting} 325 required 326 /> 327 <p class="hint">{$_('register.discordIdHint')}</p> 328 </div> 329 {:else if flow.info.verificationChannel === 'telegram'} 330 <div class="field"> 331 <label for="telegram-username">{$_('register.telegramUsername')}</label> 332 <input 333 id="telegram-username" 334 type="text" 335 bind:value={flow.info.telegramUsername} 336 placeholder={$_('register.telegramUsernamePlaceholder')} 337 disabled={flow.state.submitting} 338 required 339 /> 340 </div> 341 {:else if flow.info.verificationChannel === 'signal'} 342 <div class="field"> 343 <label for="signal-number">{$_('register.signalNumber')}</label> 344 <input 345 id="signal-number" 346 type="tel" 347 bind:value={flow.info.signalNumber} 348 placeholder={$_('register.signalNumberPlaceholder')} 349 disabled={flow.state.submitting} 350 required 351 /> 352 <p class="hint">{$_('register.signalNumberHint')}</p> 353 </div> 354 {/if} 355 </div> 356 </fieldset> 357 358 {#if serverInfo?.inviteCodeRequired} 359 <div class="field"> 360 <label for="invite-code">{$_('register.inviteCode')} <span class="required">{$_('register.inviteCodeRequired')}</span></label> 361 <input 362 id="invite-code" 363 type="text" 364 bind:value={flow.info.inviteCode} 365 placeholder={$_('register.inviteCodePlaceholder')} 366 disabled={flow.state.submitting} 367 required 368 /> 369 </div> 370 {/if} 371 372 <button type="submit" disabled={flow.state.submitting}> 373 {flow.state.submitting ? $_('register.creating') : $_('register.createButton')} 374 </button> 375 </form> 376 377 <div class="form-links"> 378 <p class="link-text"> 379 {$_('register.alreadyHaveAccount')} <a href="#/login">{$_('register.signIn')}</a> 380 </p> 381 <p class="link-text"> 382 {$_('register.wantPasswordless')} <a href="#/register-passkey">{$_('register.createPasskeyAccount')}</a> 383 </p> 384 </div> 385 </div> 386 387 <aside class="info-panel"> 388 <h3>{$_('register.identityHint')}</h3> 389 <p>{$_('register.infoIdentityDesc')}</p> 390 391 <h3>{$_('register.contactMethodHint')}</h3> 392 <p>{$_('register.infoContactDesc')}</p> 393 394 <h3>{$_('register.infoNextTitle')}</h3> 395 <p>{$_('register.infoNextDesc')}</p> 396 </aside> 397 </div> 398 399 {:else if flow.state.step === 'key-choice'} 400 <KeyChoiceStep {flow} /> 401 402 {:else if flow.state.step === 'initial-did-doc'} 403 <DidDocStep 404 {flow} 405 type="initial" 406 onConfirm={handleCreateAccount} 407 onBack={() => flow?.goBack()} 408 /> 409 410 {:else if flow.state.step === 'creating'} 411 <p class="loading">{$_('register.creating')}</p> 412 413 {:else if flow.state.step === 'verify'} 414 <VerificationStep {flow} /> 415 416 {:else if flow.state.step === 'updated-did-doc'} 417 <DidDocStep 418 {flow} 419 type="updated" 420 onConfirm={() => flow?.activateAccount()} 421 /> 422 423 {:else if flow.state.step === 'redirect-to-dashboard'} 424 <p class="loading">{$_('register.redirecting')}</p> 425 {/if} 426</div> 427 428<style> 429 .register-page { 430 max-width: var(--width-lg); 431 margin: var(--space-9) auto; 432 padding: var(--space-7); 433 } 434 435 .page-header { 436 margin-bottom: var(--space-6); 437 } 438 439 .form-section { 440 min-width: 0; 441 } 442 443 .form-links { 444 margin-top: var(--space-6); 445 } 446 447 .migrate-callout { 448 display: flex; 449 gap: var(--space-4); 450 padding: var(--space-5); 451 background: var(--accent-muted); 452 border: 1px solid var(--accent); 453 border-radius: var(--radius-xl); 454 margin-bottom: var(--space-6); 455 } 456 457 .migrate-icon { 458 font-size: var(--text-2xl); 459 line-height: 1; 460 color: var(--accent); 461 } 462 463 .migrate-content { 464 flex: 1; 465 } 466 467 .migrate-content strong { 468 display: block; 469 color: var(--text-primary); 470 margin-bottom: var(--space-2); 471 } 472 473 .migrate-content p { 474 margin: 0 0 var(--space-3) 0; 475 font-size: var(--text-sm); 476 color: var(--text-secondary); 477 line-height: var(--leading-relaxed); 478 } 479 480 .migrate-link { 481 font-size: var(--text-sm); 482 font-weight: var(--font-medium); 483 color: var(--accent); 484 text-decoration: none; 485 } 486 487 .migrate-link:hover { 488 text-decoration: underline; 489 } 490 491 h1 { 492 margin: 0 0 var(--space-3) 0; 493 } 494 495 .subtitle { 496 color: var(--text-secondary); 497 margin: 0 0 var(--space-7) 0; 498 } 499 500 .loading { 501 text-align: center; 502 color: var(--text-secondary); 503 } 504 505 form { 506 display: flex; 507 flex-direction: column; 508 gap: var(--space-5); 509 } 510 511 .required { 512 color: var(--error-text); 513 } 514 515 .section-hint { 516 font-size: var(--text-sm); 517 color: var(--text-secondary); 518 margin: 0 0 var(--space-5) 0; 519 } 520 521 .radio-group { 522 display: flex; 523 flex-direction: column; 524 gap: var(--space-4); 525 } 526 527 .radio-label { 528 display: flex; 529 align-items: flex-start; 530 gap: var(--space-3); 531 cursor: pointer; 532 font-size: var(--text-base); 533 font-weight: var(--font-normal); 534 margin-bottom: 0; 535 } 536 537 .radio-label input[type="radio"] { 538 margin-top: var(--space-1); 539 width: auto; 540 } 541 542 .radio-content { 543 display: flex; 544 flex-direction: column; 545 gap: var(--space-1); 546 } 547 548 .radio-hint { 549 font-size: var(--text-xs); 550 color: var(--text-secondary); 551 } 552 553 .warning-box { 554 margin-top: var(--space-5); 555 padding: var(--space-5); 556 background: var(--warning-bg); 557 border: 1px solid var(--warning-border); 558 border-radius: var(--radius-lg); 559 font-size: var(--text-sm); 560 } 561 562 .warning-box strong { 563 color: var(--warning-text); 564 } 565 566 .warning-box ul { 567 margin: var(--space-4) 0 0 0; 568 padding-left: var(--space-5); 569 } 570 571 .warning-box li { 572 margin-bottom: var(--space-3); 573 line-height: var(--leading-normal); 574 } 575 576 .warning-box li:last-child { 577 margin-bottom: 0; 578 } 579 580 button[type="submit"] { 581 margin-top: var(--space-3); 582 } 583 584 .link-text { 585 text-align: center; 586 margin-top: var(--space-6); 587 color: var(--text-secondary); 588 } 589 590 .link-text a { 591 color: var(--accent); 592 } 593</style>