this repo has no description
at main 19 kB view raw
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 import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte' 12 13 let serverInfo = $state<{ 14 availableUserDomains: string[] 15 inviteCodeRequired: boolean 16 availableCommsChannels?: string[] 17 selfHostedDidWebEnabled?: boolean 18 } | null>(null) 19 let loadingServerInfo = $state(true) 20 let serverInfoLoaded = false 21 22 let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null) 23 let confirmPassword = $state('') 24 25 $effect(() => { 26 if (!serverInfoLoaded) { 27 serverInfoLoaded = true 28 loadServerInfo() 29 } 30 }) 31 32 $effect(() => { 33 if (flow?.state.step === 'redirect-to-dashboard') { 34 navigate(routes.dashboard) 35 } 36 }) 37 38 let creatingStarted = false 39 $effect(() => { 40 if (flow?.state.step === 'creating' && !creatingStarted) { 41 creatingStarted = true 42 flow.createPasswordAccount() 43 } 44 }) 45 46 async function loadServerInfo() { 47 try { 48 serverInfo = await api.describeServer() 49 const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname 50 flow = createRegistrationFlow('password', hostname) 51 } catch (e) { 52 console.error('Failed to load server info:', e) 53 } finally { 54 loadingServerInfo = false 55 } 56 } 57 58 function validateInfoStep(): string | null { 59 if (!flow) return 'Flow not initialized' 60 const info = flow.info 61 if (!info.handle.trim()) return $_('register.validation.handleRequired') 62 if (info.handle.includes('.')) return $_('register.validation.handleNoDots') 63 if (!info.password) return $_('register.validation.passwordRequired') 64 if (info.password.length < 8) return $_('register.validation.passwordLength') 65 if (info.password !== confirmPassword) return $_('register.validation.passwordsMismatch') 66 if (serverInfo?.inviteCodeRequired && !info.inviteCode?.trim()) { 67 return $_('register.validation.inviteCodeRequired') 68 } 69 if (info.didType === 'web-external') { 70 if (!info.externalDid?.trim()) return $_('register.validation.externalDidRequired') 71 if (!info.externalDid.trim().startsWith('did:web:')) return $_('register.validation.externalDidFormat') 72 } 73 switch (info.verificationChannel) { 74 case 'email': 75 if (!info.email.trim()) return $_('register.validation.emailRequired') 76 break 77 case 'discord': 78 if (!info.discordId?.trim()) return $_('register.validation.discordIdRequired') 79 break 80 case 'telegram': 81 if (!info.telegramUsername?.trim()) return $_('register.validation.telegramRequired') 82 break 83 case 'signal': 84 if (!info.signalNumber?.trim()) return $_('register.validation.signalRequired') 85 break 86 } 87 return null 88 } 89 90 async function handleInfoSubmit(e: Event) { 91 e.preventDefault() 92 if (!flow) return 93 94 const validationError = validateInfoStep() 95 if (validationError) { 96 flow.setError(validationError) 97 return 98 } 99 100 flow.clearError() 101 flow.proceedFromInfo() 102 } 103 104 async function handleCreateAccount() { 105 if (!flow) return 106 await flow.createPasswordAccount() 107 } 108 109 async function handleComplete() { 110 if (flow) { 111 await flow.finalizeSession() 112 } 113 navigate(routes.dashboard) 114 } 115 116 function isChannelAvailable(ch: string): boolean { 117 const available = serverInfo?.availableCommsChannels ?? ['email'] 118 return available.includes(ch) 119 } 120 121 function channelLabel(ch: string): string { 122 switch (ch) { 123 case 'email': return $_('register.email') 124 case 'discord': return $_('register.discord') 125 case 'telegram': return $_('register.telegram') 126 case 'signal': return $_('register.signal') 127 default: return ch 128 } 129 } 130 131 let fullHandle = $derived(() => { 132 if (!flow?.info.handle.trim()) return '' 133 if (flow.info.handle.includes('.')) return flow.info.handle.trim() 134 const domain = serverInfo?.availableUserDomains?.[0] 135 if (domain) return `${flow.info.handle.trim()}.${domain}` 136 return flow.info.handle.trim() 137 }) 138 139 function extractDomain(did: string): string { 140 return did.replace('did:web:', '').replace(/%3A/g, ':') 141 } 142 143 function getSubtitle(): string { 144 if (!flow) return '' 145 switch (flow.state.step) { 146 case 'info': return $_('register.subtitle') 147 case 'key-choice': return $_('register.subtitleKeyChoice') 148 case 'initial-did-doc': return $_('register.subtitleInitialDidDoc') 149 case 'creating': return $_('common.creating') 150 case 'verify': return $_('register.subtitleVerify', { values: { channel: channelLabel(flow.info.verificationChannel) } }) 151 case 'updated-did-doc': return $_('register.subtitleUpdatedDidDoc') 152 case 'activating': return $_('register.subtitleActivating') 153 case 'redirect-to-dashboard': return $_('register.subtitleComplete') 154 default: return '' 155 } 156 } 157</script> 158 159<div class="register-page"> 160 <header class="page-header"> 161 <h1>{$_('register.title')}</h1> 162 <p class="subtitle">{getSubtitle()}</p> 163 </header> 164 165 {#if flow?.state.error} 166 <div class="message error">{flow.state.error}</div> 167 {/if} 168 169 {#if loadingServerInfo || !flow} 170 <div class="loading"></div> 171 {:else if flow.state.step === 'info'} 172 <div class="migrate-callout"> 173 <div class="migrate-icon"></div> 174 <div class="migrate-content"> 175 <strong>{$_('register.migrateTitle')}</strong> 176 <p>{$_('register.migrateDescription')}</p> 177 <a href={getFullUrl(routes.migrate)} class="migrate-link"> 178 {$_('register.migrateLink')}179 </a> 180 </div> 181 </div> 182 183 <AccountTypeSwitcher active="password" /> 184 185 <div class="split-layout sidebar-right"> 186 <div class="form-section"> 187 <form onsubmit={handleInfoSubmit}> 188 <div class="field"> 189 <label for="handle">{$_('register.handle')}</label> 190 <input 191 id="handle" 192 type="text" 193 bind:value={flow.info.handle} 194 placeholder={$_('register.handlePlaceholder')} 195 disabled={flow.state.submitting} 196 required 197 /> 198 {#if flow.info.handle.includes('.')} 199 <p class="hint warning">{$_('register.handleDotWarning')}</p> 200 {:else if fullHandle()} 201 <p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p> 202 {/if} 203 </div> 204 205 <div class="form-row"> 206 <div class="field"> 207 <label for="password">{$_('register.password')}</label> 208 <input 209 id="password" 210 type="password" 211 bind:value={flow.info.password} 212 placeholder={$_('register.passwordPlaceholder')} 213 disabled={flow.state.submitting} 214 required 215 minlength="8" 216 /> 217 </div> 218 219 <div class="field"> 220 <label for="confirm-password">{$_('register.confirmPassword')}</label> 221 <input 222 id="confirm-password" 223 type="password" 224 bind:value={confirmPassword} 225 placeholder={$_('register.confirmPasswordPlaceholder')} 226 disabled={flow.state.submitting} 227 required 228 /> 229 </div> 230 </div> 231 232 <fieldset class="section-fieldset"> 233 <legend>{$_('register.identityType')}</legend> 234 <div class="radio-group"> 235 <label class="radio-label"> 236 <input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 237 <span class="radio-content"> 238 <strong>{$_('register.didPlc')}</strong> {$_('register.didPlcRecommended')} 239 <span class="radio-hint">{$_('register.didPlcHint')}</span> 240 </span> 241 </label> 242 243 <label class="radio-label" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}> 244 <input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting || serverInfo?.selfHostedDidWebEnabled === false} /> 245 <span class="radio-content"> 246 <strong>{$_('register.didWeb')}</strong> 247 {#if serverInfo?.selfHostedDidWebEnabled === false} 248 <span class="radio-hint disabled-hint">{$_('register.didWebDisabledHint')}</span> 249 {:else} 250 <span class="radio-hint">{$_('register.didWebHint')}</span> 251 {/if} 252 </span> 253 </label> 254 255 <label class="radio-label"> 256 <input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 257 <span class="radio-content"> 258 <strong>{$_('register.didWebBYOD')}</strong> 259 <span class="radio-hint">{$_('register.didWebBYODHint')}</span> 260 </span> 261 </label> 262 </div> 263 264 {#if flow.info.didType === 'web'} 265 <div class="warning-box"> 266 <strong>{$_('register.didWebWarningTitle')}</strong> 267 <ul> 268 <li><strong>{$_('register.didWebWarning1')}</strong> {$_('register.didWebWarning1Detail', { values: { did: `did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}` } })}</li> 269 <li><strong>{$_('register.didWebWarning2')}</strong> {$_('register.didWebWarning2Detail')}</li> 270 <li><strong>{$_('register.didWebWarning3')}</strong> {$_('register.didWebWarning3Detail')}</li> 271 <li><strong>{$_('register.didWebWarning4')}</strong> {$_('register.didWebWarning4Detail')}</li> 272 </ul> 273 </div> 274 {/if} 275 276 {#if flow.info.didType === 'web-external'} 277 <div class="field"> 278 <label for="external-did">{$_('register.externalDid')}</label> 279 <input 280 id="external-did" 281 type="text" 282 bind:value={flow.info.externalDid} 283 placeholder={$_('register.externalDidPlaceholder')} 284 disabled={flow.state.submitting} 285 required 286 /> 287 <p class="hint">{$_('register.externalDidHint')}</p> 288 </div> 289 {/if} 290 </fieldset> 291 292 <fieldset class="section-fieldset"> 293 <legend>{$_('register.contactMethod')}</legend> 294 <div class="contact-fields"> 295 <div class="field"> 296 <label for="verification-channel">{$_('register.verificationMethod')}</label> 297 <select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}> 298 <option value="email">{$_('register.email')}</option> 299 <option value="discord" disabled={!isChannelAvailable('discord')}> 300 {$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`} 301 </option> 302 <option value="telegram" disabled={!isChannelAvailable('telegram')}> 303 {$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`} 304 </option> 305 <option value="signal" disabled={!isChannelAvailable('signal')}> 306 {$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`} 307 </option> 308 </select> 309 </div> 310 311 {#if flow.info.verificationChannel === 'email'} 312 <div class="field"> 313 <label for="email">{$_('register.emailAddress')}</label> 314 <input 315 id="email" 316 type="email" 317 bind:value={flow.info.email} 318 placeholder={$_('register.emailPlaceholder')} 319 disabled={flow.state.submitting} 320 required 321 /> 322 </div> 323 {:else if flow.info.verificationChannel === 'discord'} 324 <div class="field"> 325 <label for="discord-id">{$_('register.discordId')}</label> 326 <input 327 id="discord-id" 328 type="text" 329 bind:value={flow.info.discordId} 330 placeholder={$_('register.discordIdPlaceholder')} 331 disabled={flow.state.submitting} 332 required 333 /> 334 <p class="hint">{$_('register.discordIdHint')}</p> 335 </div> 336 {:else if flow.info.verificationChannel === 'telegram'} 337 <div class="field"> 338 <label for="telegram-username">{$_('register.telegramUsername')}</label> 339 <input 340 id="telegram-username" 341 type="text" 342 bind:value={flow.info.telegramUsername} 343 placeholder={$_('register.telegramUsernamePlaceholder')} 344 disabled={flow.state.submitting} 345 required 346 /> 347 </div> 348 {:else if flow.info.verificationChannel === 'signal'} 349 <div class="field"> 350 <label for="signal-number">{$_('register.signalNumber')}</label> 351 <input 352 id="signal-number" 353 type="tel" 354 bind:value={flow.info.signalNumber} 355 placeholder={$_('register.signalNumberPlaceholder')} 356 disabled={flow.state.submitting} 357 required 358 /> 359 <p class="hint">{$_('register.signalNumberHint')}</p> 360 </div> 361 {/if} 362 </div> 363 </fieldset> 364 365 {#if serverInfo?.inviteCodeRequired} 366 <div class="field"> 367 <label for="invite-code">{$_('register.inviteCode')} <span class="required">{$_('register.inviteCodeRequired')}</span></label> 368 <input 369 id="invite-code" 370 type="text" 371 bind:value={flow.info.inviteCode} 372 placeholder={$_('register.inviteCodePlaceholder')} 373 disabled={flow.state.submitting} 374 required 375 /> 376 </div> 377 {/if} 378 379 <button type="submit" disabled={flow.state.submitting}> 380 {flow.state.submitting ? $_('common.creating') : $_('register.createButton')} 381 </button> 382 </form> 383 384 <div class="form-links"> 385 <p class="link-text"> 386 {$_('register.alreadyHaveAccount')} <a href={getFullUrl(routes.login)}>{$_('register.signIn')}</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 .radio-group { 520 display: flex; 521 flex-direction: column; 522 gap: var(--space-4); 523 } 524 525 .radio-label { 526 display: flex; 527 align-items: flex-start; 528 gap: var(--space-3); 529 cursor: pointer; 530 font-size: var(--text-base); 531 font-weight: var(--font-normal); 532 margin-bottom: 0; 533 } 534 535 .radio-label input[type="radio"] { 536 margin-top: var(--space-1); 537 width: auto; 538 } 539 540 .radio-content { 541 display: flex; 542 flex-direction: column; 543 gap: var(--space-1); 544 } 545 546 .radio-hint { 547 font-size: var(--text-xs); 548 color: var(--text-secondary); 549 } 550 551 .radio-label.disabled { 552 opacity: 0.5; 553 cursor: not-allowed; 554 } 555 556 .radio-hint.disabled-hint { 557 color: var(--warning-text); 558 } 559 560 .warning-box { 561 margin-top: var(--space-5); 562 padding: var(--space-5); 563 background: var(--warning-bg); 564 border: 1px solid var(--warning-border); 565 border-radius: var(--radius-lg); 566 font-size: var(--text-sm); 567 } 568 569 .warning-box strong { 570 color: var(--warning-text); 571 } 572 573 .warning-box ul { 574 margin: var(--space-4) 0 0 0; 575 padding-left: var(--space-5); 576 } 577 578 .warning-box li { 579 margin-bottom: var(--space-3); 580 line-height: var(--leading-normal); 581 } 582 583 .warning-box li:last-child { 584 margin-bottom: 0; 585 } 586 587 button[type="submit"] { 588 margin-top: var(--space-3); 589 } 590 591 .link-text { 592 text-align: center; 593 margin-top: var(--space-6); 594 color: var(--text-secondary); 595 } 596 597 .link-text a { 598 color: var(--accent); 599 } 600</style>