Our Personal Data Server from scratch!
0

Configure Feed

Select the types of activity you want to include in your feed.

tranquil-pds / frontend / src / routes / RegisterPassword.svelte
20 kB 560 lines
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 restoreRegistrationFlow, 8 VerificationStep, 9 KeyChoiceStep, 10 DidDocStep, 11 } from '../lib/registration' 12 import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte' 13 import { ensureRequestUri, getRequestUriFromUrl } from '../lib/oauth' 14 15 let serverInfo = $state<{ 16 availableUserDomains: string[] 17 inviteCodeRequired: boolean 18 availableCommsChannels?: string[] 19 selfHostedDidWebEnabled?: boolean 20 } | null>(null) 21 let loadingServerInfo = $state(true) 22 let serverInfoLoaded = false 23 let ssoAvailable = $state(false) 24 25 let flow = $state<ReturnType<typeof createRegistrationFlow> | null>(null) 26 let confirmPassword = $state('') 27 let clientName = $state<string | null>(null) 28 29 $effect(() => { 30 if (!serverInfoLoaded) { 31 serverInfoLoaded = true 32 ensureRequestUri().then((requestUri) => { 33 if (!requestUri) return 34 loadServerInfo() 35 checkSsoAvailable() 36 fetchClientName() 37 }).catch((err) => { 38 console.error('Failed to ensure OAuth request URI:', err) 39 }) 40 } 41 }) 42 43 async function fetchClientName() { 44 const requestUri = getRequestUriFromUrl() 45 if (!requestUri) return 46 47 try { 48 const response = await fetch(`/oauth/authorize?request_uri=${encodeURIComponent(requestUri)}`, { 49 headers: { 'Accept': 'application/json' } 50 }) 51 if (response.ok) { 52 const data = await response.json() 53 clientName = data.client_name || null 54 } 55 } catch { 56 clientName = null 57 } 58 } 59 60 async function checkSsoAvailable() { 61 try { 62 const response = await fetch('/oauth/sso/providers') 63 if (response.ok) { 64 const data = await response.json() 65 ssoAvailable = (data.providers?.length ?? 0) > 0 66 } 67 } catch { 68 ssoAvailable = false 69 } 70 } 71 72 $effect(() => { 73 if (flow?.state.step === 'redirect-to-dashboard') { 74 completeOAuthRegistration() 75 } 76 }) 77 78 let creatingStarted = false 79 $effect(() => { 80 if (flow?.state.step === 'creating' && !creatingStarted) { 81 creatingStarted = true 82 flow.createPasswordAccount() 83 } 84 }) 85 86 async function loadServerInfo() { 87 try { 88 const restored = restoreRegistrationFlow() 89 if (restored && restored.state.mode === 'password') { 90 flow = restored 91 serverInfo = await api.describeServer() 92 } else { 93 serverInfo = await api.describeServer() 94 const hostname = serverInfo?.availableUserDomains?.[0] || window.location.hostname 95 flow = createRegistrationFlow('password', hostname) 96 } 97 } catch (e) { 98 console.error('Failed to load server info:', e) 99 } finally { 100 loadingServerInfo = false 101 } 102 } 103 104 function validateInfoStep(): string | null { 105 if (!flow) return 'Flow not initialized' 106 const info = flow.info 107 if (!info.handle.trim()) return $_('register.validation.handleRequired') 108 if (info.handle.includes('.')) return $_('register.validation.handleNoDots') 109 if (!info.password) return $_('register.validation.passwordRequired') 110 if (info.password.length < 8) return $_('register.validation.passwordLength') 111 if (info.password !== confirmPassword) return $_('register.validation.passwordsMismatch') 112 if (serverInfo?.inviteCodeRequired && !info.inviteCode?.trim()) { 113 return $_('register.validation.inviteCodeRequired') 114 } 115 if (info.didType === 'web-external') { 116 if (!info.externalDid?.trim()) return $_('register.validation.externalDidRequired') 117 if (!info.externalDid.trim().startsWith('did:web:')) return $_('register.validation.externalDidFormat') 118 } 119 switch (info.verificationChannel) { 120 case 'email': 121 if (!info.email.trim()) return $_('register.validation.emailRequired') 122 break 123 case 'discord': 124 if (!info.discordId?.trim()) return $_('register.validation.discordIdRequired') 125 break 126 case 'telegram': 127 if (!info.telegramUsername?.trim()) return $_('register.validation.telegramRequired') 128 break 129 case 'signal': 130 if (!info.signalNumber?.trim()) return $_('register.validation.signalRequired') 131 break 132 } 133 return null 134 } 135 136 async function handleInfoSubmit(e: Event) { 137 e.preventDefault() 138 if (!flow) return 139 140 const validationError = validateInfoStep() 141 if (validationError) { 142 flow.setError(validationError) 143 return 144 } 145 146 flow.clearError() 147 flow.proceedFromInfo() 148 } 149 150 async function handleCreateAccount() { 151 if (!flow) return 152 await flow.createPasswordAccount() 153 } 154 155 async function handleComplete() { 156 if (flow) { 157 await flow.finalizeSession() 158 } 159 navigate(routes.dashboard) 160 } 161 162 async function completeOAuthRegistration() { 163 const requestUri = getRequestUriFromUrl() 164 if (!requestUri || !flow?.account) { 165 navigate(routes.dashboard) 166 return 167 } 168 169 try { 170 const response = await fetch('/oauth/register/complete', { 171 method: 'POST', 172 headers: { 173 'Content-Type': 'application/json', 174 'Accept': 'application/json', 175 }, 176 body: JSON.stringify({ 177 request_uri: requestUri, 178 did: flow.account.did, 179 app_password: flow.account.appPassword, 180 }), 181 }) 182 183 const data = await response.json() 184 185 if (!response.ok) { 186 flow.setError(data.error_description || data.error || $_('common.error')) 187 return 188 } 189 190 if (data.redirect_uri) { 191 window.location.href = data.redirect_uri 192 return 193 } 194 195 navigate(routes.dashboard) 196 } catch (err) { 197 console.error('OAuth registration completion failed:', err) 198 flow.setError(err instanceof Error ? err.message : $_('common.error')) 199 } 200 } 201 202 function isChannelAvailable(ch: string): boolean { 203 const available = serverInfo?.availableCommsChannels ?? ['email'] 204 return available.includes(ch) 205 } 206 207 function channelLabel(ch: string): string { 208 switch (ch) { 209 case 'email': return $_('register.email') 210 case 'discord': return $_('register.discord') 211 case 'telegram': return $_('register.telegram') 212 case 'signal': return $_('register.signal') 213 default: return ch 214 } 215 } 216 217 let fullHandle = $derived(() => { 218 if (!flow?.info.handle.trim()) return '' 219 if (flow.info.handle.includes('.')) return flow.info.handle.trim() 220 const domain = serverInfo?.availableUserDomains?.[0] 221 if (domain) return `${flow.info.handle.trim()}.${domain}` 222 return flow.info.handle.trim() 223 }) 224 225 function extractDomain(did: string): string { 226 return did.replace('did:web:', '').replace(/%3A/g, ':') 227 } 228 229 function getSubtitle(): string { 230 if (!flow) return '' 231 switch (flow.state.step) { 232 case 'info': return $_('register.subtitle') 233 case 'key-choice': return $_('register.subtitleKeyChoice') 234 case 'initial-did-doc': return $_('register.subtitleInitialDidDoc') 235 case 'creating': return $_('common.creating') 236 case 'verify': return $_('register.subtitleVerify', { values: { channel: channelLabel(flow.info.verificationChannel) } }) 237 case 'updated-did-doc': return $_('register.subtitleUpdatedDidDoc') 238 case 'activating': return $_('register.subtitleActivating') 239 case 'redirect-to-dashboard': return $_('register.subtitleComplete') 240 default: return '' 241 } 242 } 243</script> 244 245<div class="page"> 246 <header class="page-header"> 247 <h1>{$_('register.title')}</h1> 248 <p class="subtitle">{getSubtitle()}</p> 249 {#if clientName} 250 <p class="client-name">{$_('oauth.login.subtitle')} <strong>{clientName}</strong></p> 251 {/if} 252 </header> 253 254 {#if flow?.state.error} 255 <div class="message error">{flow.state.error}</div> 256 {/if} 257 258 {#if loadingServerInfo || !flow} 259 <div class="loading"> 260 <div class="spinner md"></div> 261 </div> 262 {:else if flow.state.step === 'info'} 263 <div class="migrate-callout"> 264 <div class="migrate-icon"></div> 265 <div class="migrate-content"> 266 <strong>{$_('register.migrateTitle')}</strong> 267 <p>{$_('register.migrateDescription')}</p> 268 <a href={getFullUrl(routes.migrate)} class="migrate-link"> 269 {$_('register.migrateLink')}270 </a> 271 </div> 272 </div> 273 274 <AccountTypeSwitcher active="password" {ssoAvailable} oauthRequestUri={getRequestUriFromUrl()} /> 275 276 <div class="split-layout sidebar-right"> 277 <div class="form-section"> 278 <form onsubmit={handleInfoSubmit}> 279 <div class="field"> 280 <label for="handle">{$_('register.handle')}</label> 281 <input 282 id="handle" 283 type="text" 284 bind:value={flow.info.handle} 285 placeholder={$_('register.handlePlaceholder')} 286 disabled={flow.state.submitting} 287 required 288 /> 289 {#if flow.info.handle.includes('.')} 290 <p class="hint warning">{$_('register.handleDotWarning')}</p> 291 {:else if fullHandle()} 292 <p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p> 293 {/if} 294 </div> 295 296 <div class="form-row"> 297 <div class="field"> 298 <label for="password">{$_('register.password')}</label> 299 <input 300 id="password" 301 type="password" 302 bind:value={flow.info.password} 303 placeholder={$_('register.passwordPlaceholder')} 304 disabled={flow.state.submitting} 305 required 306 minlength="8" 307 /> 308 </div> 309 310 <div class="field"> 311 <label for="confirm-password">{$_('register.confirmPassword')}</label> 312 <input 313 id="confirm-password" 314 type="password" 315 bind:value={confirmPassword} 316 placeholder={$_('register.confirmPasswordPlaceholder')} 317 disabled={flow.state.submitting} 318 required 319 /> 320 </div> 321 </div> 322 323 <fieldset class="section-fieldset"> 324 <legend>{$_('register.identityType')}</legend> 325 <div class="radio-group"> 326 <label class="radio-label"> 327 <input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 328 <span class="radio-content"> 329 <strong>{$_('register.didPlc')}</strong> {$_('register.didPlcRecommended')} 330 <span class="radio-hint">{$_('register.didPlcHint')}</span> 331 </span> 332 </label> 333 334 <label class="radio-label" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}> 335 <input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting || serverInfo?.selfHostedDidWebEnabled === false} /> 336 <span class="radio-content"> 337 <strong>{$_('register.didWeb')}</strong> 338 {#if serverInfo?.selfHostedDidWebEnabled === false} 339 <span class="radio-hint disabled-hint">{$_('register.didWebDisabledHint')}</span> 340 {:else} 341 <span class="radio-hint">{$_('register.didWebHint')}</span> 342 {/if} 343 </span> 344 </label> 345 346 <label class="radio-label"> 347 <input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 348 <span class="radio-content"> 349 <strong>{$_('register.didWebBYOD')}</strong> 350 <span class="radio-hint">{$_('register.didWebBYODHint')}</span> 351 </span> 352 </label> 353 </div> 354 355 {#if flow.info.didType === 'web'} 356 <div class="warning-box"> 357 <strong>{$_('register.didWebWarningTitle')}</strong> 358 <ul> 359 <li><strong>{$_('register.didWebWarning1')}</strong> {$_('register.didWebWarning1Detail', { values: { did: `did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}` } })}</li> 360 <li><strong>{$_('register.didWebWarning2')}</strong> {$_('register.didWebWarning2Detail')}</li> 361 <li><strong>{$_('register.didWebWarning3')}</strong> {$_('register.didWebWarning3Detail')}</li> 362 <li><strong>{$_('register.didWebWarning4')}</strong> {$_('register.didWebWarning4Detail')}</li> 363 </ul> 364 </div> 365 {/if} 366 367 {#if flow.info.didType === 'web-external'} 368 <div class="field"> 369 <label for="external-did">{$_('register.externalDid')}</label> 370 <input 371 id="external-did" 372 type="text" 373 bind:value={flow.info.externalDid} 374 placeholder={$_('register.externalDidPlaceholder')} 375 disabled={flow.state.submitting} 376 required 377 /> 378 <p class="hint">{$_('register.externalDidHint')}</p> 379 </div> 380 {/if} 381 </fieldset> 382 383 <fieldset class="section-fieldset"> 384 <legend>{$_('register.contactMethod')}</legend> 385 <div class="contact-fields"> 386 <div class="field"> 387 <label for="verification-channel">{$_('register.verificationMethod')}</label> 388 <select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}> 389 <option value="email">{$_('register.email')}</option> 390 <option value="discord" disabled={!isChannelAvailable('discord')}> 391 {$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`} 392 </option> 393 <option value="telegram" disabled={!isChannelAvailable('telegram')}> 394 {$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`} 395 </option> 396 <option value="signal" disabled={!isChannelAvailable('signal')}> 397 {$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`} 398 </option> 399 </select> 400 </div> 401 402 {#if flow.info.verificationChannel === 'email'} 403 <div class="field"> 404 <label for="email">{$_('register.emailAddress')}</label> 405 <input 406 id="email" 407 type="email" 408 bind:value={flow.info.email} 409 onblur={() => flow?.checkEmailInUse(flow.info.email)} 410 placeholder={$_('register.emailPlaceholder')} 411 disabled={flow.state.submitting} 412 required 413 /> 414 {#if flow.state.emailInUse} 415 <p class="hint warning">{$_('register.emailInUseWarning')}</p> 416 {/if} 417 </div> 418 {:else if flow.info.verificationChannel === 'discord'} 419 <div class="field"> 420 <label for="discord-id">{$_('register.discordId')}</label> 421 <input 422 id="discord-id" 423 type="text" 424 bind:value={flow.info.discordId} 425 onblur={() => flow?.checkCommsChannelInUse('discord', flow.info.discordId ?? '')} 426 placeholder={$_('register.discordIdPlaceholder')} 427 disabled={flow.state.submitting} 428 required 429 /> 430 <p class="hint">{$_('register.discordIdHint')}</p> 431 {#if flow.state.discordInUse} 432 <p class="hint warning">{$_('register.discordInUseWarning')}</p> 433 {/if} 434 </div> 435 {:else if flow.info.verificationChannel === 'telegram'} 436 <div class="field"> 437 <label for="telegram-username">{$_('register.telegramUsername')}</label> 438 <input 439 id="telegram-username" 440 type="text" 441 bind:value={flow.info.telegramUsername} 442 onblur={() => flow?.checkCommsChannelInUse('telegram', flow.info.telegramUsername ?? '')} 443 placeholder={$_('register.telegramUsernamePlaceholder')} 444 disabled={flow.state.submitting} 445 required 446 /> 447 {#if flow.state.telegramInUse} 448 <p class="hint warning">{$_('register.telegramInUseWarning')}</p> 449 {/if} 450 </div> 451 {:else if flow.info.verificationChannel === 'signal'} 452 <div class="field"> 453 <label for="signal-number">{$_('register.signalNumber')}</label> 454 <input 455 id="signal-number" 456 type="tel" 457 bind:value={flow.info.signalNumber} 458 onblur={() => flow?.checkCommsChannelInUse('signal', flow.info.signalNumber ?? '')} 459 placeholder={$_('register.signalNumberPlaceholder')} 460 disabled={flow.state.submitting} 461 required 462 /> 463 <p class="hint">{$_('register.signalNumberHint')}</p> 464 {#if flow.state.signalInUse} 465 <p class="hint warning">{$_('register.signalInUseWarning')}</p> 466 {/if} 467 </div> 468 {/if} 469 </div> 470 </fieldset> 471 472 {#if serverInfo?.inviteCodeRequired} 473 <div class="field"> 474 <label for="invite-code">{$_('register.inviteCode')} <span class="required">{$_('register.inviteCodeRequired')}</span></label> 475 <input 476 id="invite-code" 477 type="text" 478 bind:value={flow.info.inviteCode} 479 placeholder={$_('register.inviteCodePlaceholder')} 480 disabled={flow.state.submitting} 481 required 482 /> 483 </div> 484 {/if} 485 486 <button type="submit" disabled={flow.state.submitting}> 487 {flow.state.submitting ? $_('common.creating') : $_('register.createButton')} 488 </button> 489 </form> 490 491 <div class="form-links"> 492 <p class="link-text"> 493 {$_('register.alreadyHaveAccount')} <a href={getFullUrl(routes.login)}>{$_('register.signIn')}</a> 494 </p> 495 </div> 496 </div> 497 498 <aside class="info-panel"> 499 <h3>{$_('register.identityHint')}</h3> 500 <p>{$_('register.infoIdentityDesc')}</p> 501 502 <h3>{$_('register.contactMethodHint')}</h3> 503 <p>{$_('register.infoContactDesc')}</p> 504 505 <h3>{$_('register.infoNextTitle')}</h3> 506 <p>{$_('register.infoNextDesc')}</p> 507 </aside> 508 </div> 509 510 {:else if flow.state.step === 'key-choice'} 511 <KeyChoiceStep {flow} /> 512 513 {:else if flow.state.step === 'initial-did-doc'} 514 <DidDocStep 515 {flow} 516 type="initial" 517 onConfirm={handleCreateAccount} 518 onBack={() => flow?.goBack()} 519 /> 520 521 {:else if flow.state.step === 'creating'} 522 <div class="loading"> 523 <div class="spinner md"></div> 524 <p>{$_('common.creating')}</p> 525 </div> 526 527 {:else if flow.state.step === 'verify'} 528 <VerificationStep {flow} /> 529 530 {:else if flow.state.step === 'updated-did-doc'} 531 <DidDocStep 532 {flow} 533 type="updated" 534 onConfirm={() => flow?.activateAccount()} 535 /> 536 537 {:else if flow.state.step === 'redirect-to-dashboard'} 538 <div class="loading"> 539 <div class="spinner md"></div> 540 <p>{$_('register.redirecting')}</p> 541 </div> 542 {/if} 543</div> 544 545<style> 546 .client-name { 547 color: var(--text-secondary); 548 margin-top: var(--space-2); 549 } 550 551 form { 552 display: flex; 553 flex-direction: column; 554 gap: var(--space-5); 555 } 556 557 button[type="submit"] { 558 margin-top: var(--space-3); 559 } 560</style>