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