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