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